diff --git a/.github/actions/prepare-env/action.yml b/.github/actions/prepare-env/action.yml index 668471217f..b25116e4cd 100644 --- a/.github/actions/prepare-env/action.yml +++ b/.github/actions/prepare-env/action.yml @@ -19,17 +19,10 @@ runs: with: node-version: ${{ inputs.node-version }} - - name: patch node gyp on windows to support Visual Studio 2019 - if: ${{ inputs.os == 'windows-latest' || inputs.os == 'windows-2019' }} - shell: powershell - run: | - npm install --global node-gyp@latest - npm prefix -g | % {npm config set node_gyp "$_\node_modules\node-gyp\bin\node-gyp.js"} - - name: Install dependencies (Non-Windows) if: ${{ inputs.os != 'windows-latest' && inputs.os != 'windows-2019' }} shell: bash - run: npm ci + run: npm ci --foreground-scripts - name: Install dependencies (Windows) if: ${{ inputs.os == 'windows-latest' || inputs.os == 'windows-2019' }} diff --git a/.github/workflows/build-test.js.yml b/.github/workflows/build-test.js.yml index e6399d9c99..e68aecd583 100644 --- a/.github/workflows/build-test.js.yml +++ b/.github/workflows/build-test.js.yml @@ -27,6 +27,7 @@ jobs: src: - "package.json" - "packages/**/package.json" + - ".github/workflows/build-test.js.yml" - name: Prepare testing environment uses: ./.github/actions/prepare-env - run: npm run format-verify @@ -38,7 +39,8 @@ jobs: strategy: matrix: node-version: [ 16.x, 18.x, 20.x ] - os: [ macos-latest, windows-2019 ] + # os: [ macos-latest, windows-2019 ] + os: [ windows-2019 ] steps: - uses: actions/checkout@v4 if: ${{ needs.check-and-lint.outputs.package-changes == 'true' }} diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index e2e8838781..3b3920afc1 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -29,7 +29,8 @@ jobs: strategy: matrix: node-version: [ 16.x, 18.x, 20.x ] - os: [ macos-latest, windows-2019 ] + # os: [ macos-latest, windows-2019 ] + os: [ windows-2019 ] steps: - uses: actions/checkout@v4 - name: Build on ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f5df182d7b..e641e605d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The main work (all changes without a GitHub username in brackets in the below li * Feature: Added generation method for random passcodes to PaseClient * Feature: Generalized Discovery logic and allow discoveries via different methods (BLE+IP) in parallel * Feature: Added functionality to clear session contexts including data in sub-contexts or not + * Feature: Enhance discovery methods to allow continuous discovery for operational devices * matter.js API: * Breaking: Rename resetStorage() on CommissioningServer to factoryReset() and add logic to restart the device if currently running * Breaking: Restructure the CommissioningController to allow pairing with multiple nodes @@ -33,7 +34,10 @@ The main work (all changes without a GitHub username in brackets in the below li * Introducing class PairedNode with the High level API for a paired Node * Restructured CommissioningController to handle multiple nodes and offer new high level API * Changed name of the unique storage id for servers or controllers added to MatterServer to "uniqueStorageKey" + * Adjusted subscription callbacks to also provide the nodeId of the affected device reporting the changes to allow callbacks to be used generically when connecting to all nodes + * Introduces a node state information callback to inform about the connection status but also when the node structure changed (for bridges) or such. * Breaking: option "mdnsAnnounceInterface" was deprecated and replaced by "mdnsInterface" and now used to limit announcements and scanning to a specific interface + * Feature: Enhanced CommissioningServer API and CommissioningController for improved practical usage * Feature: Makes Port for CommissioningServer optional and add automatic port handling in MatterServer * Feature: Allows removal of Controller or Server instances from Matter server, optionally with deleting the storage * Enhance: Makes passcode and discriminator for CommissioningServer optional and randomly generate them if not provided diff --git a/README.md b/README.md index 26fbf5496b..d13d472836 100644 --- a/README.md +++ b/README.md @@ -160,44 +160,68 @@ Table Legend: - "X" means supported - "-" means not supported from current knowledge - "?" means unknown - -| Device type | Apple | Google | Amazon | SmartThings | LG ThinQ | Tuya | -| --------------------------------- | ----- | ------------- | ------ | ----------- | -------- | ---- | -| **Bridge Support** | X | X | X | X | - | - | -| **Composed Devices Support** | X | X | - | - | - | - | -| **Light devices** | | | | | | | -| * On/Off Light | X | X | X | X | X | X | -| * Dimmable Light | X | X | X | X | - | X | -| * Color Temperature Light | X | X | X | X | - | X | -| * Extended Color Light | X | X | X | ? | - | ? | -| **Smart Plugs/Outlets/Actuators** | | | | | | | -| * On/Off Plug-in Unit | X | X | X | X | X | X | -| * Dimmable Plug-in Unit | ? | ? | ? | ? | - | ? | -| * Pump | - | - | - | - | - | - | -| **Sensors** | | | | | | | -| * Contact Sensor | X | X | X | X | ? | ? | -| * Light Sensor | X | X | - | X | ? | ? | -| * Occupancy Sensor | X | X | - | ? | ? | ? | -| * Temperature Sensor | X | X | - | X | ? | ? | -| * Pressure Sensor | - | X | - | ? | ? | ? | -| * Flow Sensor | - | X | - | X | ? | ? | -| * Humidity Sensor | X | X | - | X | ? | ? | -| * On/Off Sensor | ? | ? | ? | ? | ? | ? | -| **Closure Devices** | | | | | | | -| * Door Lock | - | X | X | ? | ? | ? | -| * Window Covering | X | X (Lift only) | - | ? | ? | ? | -| **HVAC Devices** | | | | | | | -| * Heating/Cooling-Unit | - | - | - | - | ? | ? | -| * Thermostat | X | X | X | ? | ? | ? | -| * Fan | - | - | - | - | ? | ? | -| **Media Devices** | | | | | | | -| * Video Player Architecture | - | - | - | - | ? | - | -| * Basic Video Player | - | - | - | - | ? | - | -| * Casting Video Player | - | - | - | - | ? | - | -| * Speaker | | X | - | - | ? | - | -| * Content App | - | - | - | - | ? | - | -| **Generic Devices** | | | | | | | -| * Mode Select | - | - | X | - | ? | - | +- "MDL Section" column identifies the relevant section in the Matter Device Library Specification Version 1.2 (Oct. 18, 2023). +- "ID" refers to the device type ID as set out in each device type's "Classification" section in the the MDL. + +| **MDL Section** | **Matter Device type and Class** | **Apple
iOS 17.1** | **Google** | **Amazon** | **SmartThings** | **LG ThinQ** | **Tuya** | **Home Assisitant
2023.11.0** | +|------------------|-----------------------------------------------------|:-----------------------:|:-------------:|:----------:|:---------------:|:------------:|:--------:|:---------------------------------:| +| | **Bridge Support** | X | X | X | X | - | - | ? | +| | **Composed Devices Support** | X | X | - | - | - | - | ? | +| 4 | **Lighting Device Types** | | | | | | | | +| 4.1 | On/Off Light
(ID: 0x0100) | X | X | X | X | X | X | X | +| 4.2 | Dimmable Light
(ID: 0x0101) | X | X | X | X | - | X | X | +| 4.3 | Color Temperature Light
(ID: 0x010C) | X | X | X | X | - | X | X | +| 4.4 | Extended Color Light
(ID: 0x010D) | X | X | X | ? | - | ? | X | +| 5 | **Smart Plugs/Outlets/Actuators** | | | | | | | | +| 5.1 | On/Off Plug-in Unit
(ID: 0x010A) | X | X | X | X | X | X | X | +| 5.2 | Dimmable Plug-in Unit
(ID: 0x010B) | ? | ? | ? | ? | - | ? | ? | +| 5.3 | Pump
(ID: 0x0303) | - | - | - | - | - | - | - | +| 6 | **Switches and Control Device Types** | | | | | | | | +| 6.1 | On/Off Light Switch
(ID: 0x0103) | x | x | ? | ? | ? | ? | x | +| 6.2 | Dimmer Switch
(ID: 0x0104) | X | x | ? | ? | ? | ? | x | +| 6.3 | Color Dimmer Switch
(ID: 0x0105) | ? | ? | ? | ? | ? | ? | ? | +| 6.4 | Control Bridge
(ID: 0x0840) | ? | ? | ? | ? | ? | ? | ? | +| 6.5 | Pump Controller
(ID: 0x0304) | ? | ? | ? | ? | ? | ? | ? | +| 6.6 | Generic Switch
(ID: 0x000F) | X | - | ? | ? | ? | ? | X | +| 7 | **Sensor Device Types** | | | | | | | | +| 7.1 | Contact Sensor
(ID: 0x0015) | X | X | X | X | ? | ? | X | +| 7.2 | Light Sensor
(ID: 0x0106) | X | X | - | X | ? | ? | ? | +| 7.3 | Occupancy Sensor
(ID: 0x0107) | X | X | - | ? | ? | ? | ? | +| 7.4 | Temperature Sensor
(ID: 0x0302) | X | X | - | X | ? | ? | ? | +| 7.5 | Pressure Sensor
(ID: 0x0305) | - | X | - | ? | ? | ? | ? | +| 7.6 | Flow Sensor
(ID: 0x0306) | - | X | - | X | ? | ? | ? | +| 7.7 | Humidity Sensor
(ID: 0x0307) | X | X | - | X | ? | ? | ? | +| 7.8 | On/Off Sensor
(ID: 0x0850) | ? | ? | ? | ? | ? | ? | ? | +| 7.9 | Smoke CO Alarm
(ID: 0x0076) | ? | ? | ? | ? | ? | ? | ? | +| 8 | **Closure Device Types** | | | | | | | | +| 8.1 | Door Lock
(ID: 0x000A) | X | X | X | X | ? | ? | X | +| 8.2 | Door Lock Controller
(ID: 0x000B) | | | | | | | ? | +| 8.3 | Window Covering
(ID: 0x0202) | X | X (Lift only) | - | ? | ? | ? | X | +| 8.4 | Window Covering Controller
(ID: 0x0203) | ? | ? | ? | ? | ? | ? | ? | +| 9 | **HVAC Device Types** | | | | | | | | +| 9.1 | Heating/Cooling-Unit
(ID: 0x0300) | - | - | - | - | ? | ? | ? | +| 9.2 | Thermostat
(ID: 0x0301) | X | X | X | ? | ? | ? | ? | +| 9.3 | Fan
(ID: 0x002B) | - | - | - | - | ? | ? | ? | +| 9.4 | Air Purifier
(ID: 0x002D) | ? | ? | ? | ? | ? | ? | ? | +| 9.5 | Air Quality Sensor
(ID: 0x002C) | ? | ? | ? | ? | ? | ? | ? | +| 10 | **Media Devices** | | | | | | | | +| 10.1 | Video Player Architecture | - | - | - | - | ? | - | ? | +| 10.2 | Basic Video Player
(ID: 0x0028) | - | - | - | - | ? | - | ? | +| 10.3 | Casting Video Player
(ID: 0x0023) | - | - | - | - | ? | - | ? | +| 10.4 | Speaker
(ID: 0x0022) | | X | - | - | ? | - | ? | +| 10.5 | Content App
(ID: 0x0024) | - | - | - | - | ? | - | ? | +| 10.6 | Casting Video Client
(ID: 0x0029) | ? | ? | ? | ? | ? | ? | ? | +| 10.7 | Video Remote Control
(ID: 0x002A) | ? | ? | ? | ? | ? | ? | ? | +| 11 | **Generic Devices** | | | | | | | | +| 11.1 | Mode Select
(ID: 0x0027) | - | - | X | - | ? | - | ? | +| 12 | **Robotic Device Types** | | | | | | | | +| 12.1 | Robotic Vacuum Cleaner Device Type
(ID: 0x0074) | ? | ? | ? | ? | ? | ? | ? | +| 13 | **Appliance Device Types** | | | | | | | | +| 13.1 | Laundry Washer
(ID: 0x0073) | ? | ? | ? | ? | ? | ? | ? | +| 13.2 | Refrigerator
(ID: 0x0070) | ? | ? | ? | ? | ? | ? | ? | +| 13.3 | Room Air Conditioner
(ID: 0x0072) | ? | ? | ? | ? | ? | ? | ? | +| 13.4 | Temperature Controlled Cabinet
(ID: 0x0071) | ? | ? | ? | ? | ? | ? | ? | +| 13.5 | Dishwasher
(ID: 0x0075) | ? | ? | ? | ? | ? | ? | ? | ## Pairing and Usage Information @@ -205,7 +229,7 @@ It should work with any Matter-compatible home automation app when Matter will b ### Apple -Minimum OS Required for iOS and tvOS devices: iOS 16.2 or later. +Minimum OS Required for iOS and tvOS devices: iOS 16.2 or later, with latest iOS version recommended. Apple has two guides that can help you get setup: @@ -265,7 +289,7 @@ control Matter smart home devices. Currently, Samsung SmartThings Station or Sam ### Home Assistant - Matter integration -Home Assistant's official Matter integration is in Beta-stage however it is fully working and compatible with the Matter 1.0 standard. To connect Thread based devices you also need an Thread Border Router radio that is compatible with Home Assistant's official Thread integration, that includes their official Home Assistant Yellow hub and their Home Assistant SkyConnect Zigbee/Thread USB dongle. +Home Assistant's official Matter integration is in Beta-stage however it is fully working and compatible with the Matter 1.2 standard. To connect Thread based devices you also need an Thread Border Router radio that is compatible with Home Assistant's official Thread integration, that includes their official Home Assistant Yellow hub and their Home Assistant SkyConnect Zigbee/Thread USB dongle. - [https://www.home-assistant.io/integrations/matter/](https://www.home-assistant.io/integrations/matter/) - [https://www.home-assistant.io/integrations/thread/](https://www.home-assistant.io/integrations/thread/) diff --git a/chip-testing/package.json b/chip-testing/package.json index a93b46f08f..20421026b9 100644 --- a/chip-testing/package.json +++ b/chip-testing/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter.js-chip-testing", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Testing of matter.js with CHIP tool", "private": true, "license": "Apache-2.0", @@ -20,8 +20,8 @@ "test-chip": "matter-test --force-exit" }, "dependencies": { - "@project-chip/matter-node.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter-node.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6" } } diff --git a/codegen/package.json b/codegen/package.json index 7dacb33981..c73ae49a36 100644 --- a/codegen/package.json +++ b/codegen/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter.js-codegen", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Matter.js tooling", "private": true, "type": "module", @@ -27,12 +27,12 @@ }, "homepage": "https://github.com/project-chip/matter.js#readme", "dependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js-intermediate-models": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js-intermediate-models": "0.6.1-alpha.0-20231106-8322fa6" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", - "@types/jsdom": "^21.1.3", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", + "@types/jsdom": "^21.1.4", "jsdom": "^22.1.0", "typescript": "^5.2.2", "word-list": "^3.1.0" diff --git a/lerna.json b/lerna.json index 915f914568..f28c74b052 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "command": { "run": { "stream": true diff --git a/models/package.json b/models/package.json index c6ebfe2245..dacdaa31c3 100644 --- a/models/package.json +++ b/models/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter.js-intermediate-models", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Matter.js intermediate models", "private": true, "type": "module", @@ -22,7 +22,7 @@ }, "homepage": "https://github.com/project-chip/matter.js#readme", "devDependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6" } } diff --git a/package-lock.json b/package-lock.json index 2900e769f9..35cb4934fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,11 @@ ], "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.7.4", - "@typescript-eslint/parser": "^6.9.0", + "@typescript-eslint/parser": "^6.9.1", "eslint": "^8.51.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.0", - "lerna": "^7.4.1", + "lerna": "^7.4.2", "prettier": "^3.0.3", "prettier-plugin-organize-imports": "^3.2.3", "semver": "^7.5.4", @@ -34,25 +34,25 @@ }, "chip-testing": { "name": "@project-chip/matter.js-chip-testing", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { - "@project-chip/matter-node.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter-node.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6" } }, "codegen": { "name": "@project-chip/matter.js-codegen", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js-intermediate-models": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js-intermediate-models": "0.6.1-alpha.0-20231106-8322fa6" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", - "@types/jsdom": "^21.1.3", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", + "@types/jsdom": "^21.1.4", "jsdom": "^22.1.0", "typescript": "^5.2.2", "word-list": "^3.1.0" @@ -60,11 +60,11 @@ }, "models": { "name": "@project-chip/matter.js-intermediate-models", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "devDependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -869,9 +869,9 @@ } }, "node_modules/@lerna/child-process": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@lerna/child-process/-/child-process-7.4.1.tgz", - "integrity": "sha512-Bx1cRCZcVcWoz+atDQc4CSVzGuEgGJPOpIAXjQbBEA2cX5nqIBWdbye8eHu31En/F03aH9BhpNEJghs6wy4iTg==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@lerna/child-process/-/child-process-7.4.2.tgz", + "integrity": "sha512-je+kkrfcvPcwL5Tg8JRENRqlbzjdlZXyaR88UcnCdNW0AJ1jX9IfHRys1X7AwSroU2ug8ESNC+suoBw1vX833Q==", "dev": true, "dependencies": { "chalk": "^4.1.0", @@ -883,12 +883,12 @@ } }, "node_modules/@lerna/create": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@lerna/create/-/create-7.4.1.tgz", - "integrity": "sha512-zPO9GyWceRimtMD+j+aQ8xJgNPYn/Q/SzHf4wYN+4Rj5nrFKMyX+Et7FbWgUNpj0dRgyCCKBDYmTB7xQVVq4gQ==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@lerna/create/-/create-7.4.2.tgz", + "integrity": "sha512-1wplFbQ52K8E/unnqB0Tq39Z4e+NEoNrpovEnl6GpsTUrC6WDp8+w0Le2uCBV0hXyemxChduCkLz4/y1H1wTeg==", "dev": true, "dependencies": { - "@lerna/child-process": "7.4.1", + "@lerna/child-process": "7.4.2", "@npmcli/run-script": "6.0.2", "@nx/devkit": ">=16.5.1 < 17", "@octokit/plugin-enterprise-rest": "6.0.1", @@ -2431,9 +2431,9 @@ "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==" }, "node_modules/@types/jsdom": { - "version": "21.1.3", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.3.tgz", - "integrity": "sha512-1zzqSP+iHJYV4lB3lZhNBa012pubABkj9yG/GuXuf6LZH1cSPIJBqFDrm5JX65HHt6VOnNYdTui/0ySerRbMgA==", + "version": "21.1.4", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.4.tgz", + "integrity": "sha512-NzAMLEV0KQ4cBaDx3Ls8VfJUElyDUm1xrtYRmcMK0gF8L5xYbujFVaQlJ50yinQ/d47j2rEP1XUzkiYrw4YRFA==", "dev": true, "dependencies": { "@types/node": "*", @@ -2498,10 +2498,13 @@ } }, "node_modules/@types/node-localstorage": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@types/node-localstorage/-/node-localstorage-1.3.1.tgz", - "integrity": "sha512-tfE27I0XNLHjmK6fJh00ZB+0bpyJbz/hp4PDuDtIUlzs2bI8v5Q0QrQYPpV5TR1XtzgPlUoH3ZDSo/++01IBBQ==", - "dev": true + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/node-localstorage/-/node-localstorage-1.3.2.tgz", + "integrity": "sha512-i3KDc7Y5xQgR395Gw9nn0H/HbY0VC131pqfF2PYMbvNpvDugJ95xpSseudiJV4vcQxnj2g24yys83eMEB+Ewgw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.3", @@ -2629,9 +2632,9 @@ "dev": true }, "node_modules/@types/yargs": { - "version": "17.0.28", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.28.tgz", - "integrity": "sha512-N3e3fkS86hNhtk6BEnc0rj3zcehaxx8QWhCROJkqpl5Zaoi7nAic3jH8q94jVD3zu5LGk+PUB6KAiDmimYOEQw==", + "version": "17.0.29", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", + "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", "dev": true, "dependencies": { "@types/yargs-parser": "*" @@ -2679,15 +2682,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.9.0.tgz", - "integrity": "sha512-GZmjMh4AJ/5gaH4XF2eXA8tMnHWP+Pm1mjQR2QN4Iz+j/zO04b9TOvJYOX2sCNIQHtRStKTxRY1FX7LhpJT4Gw==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.9.1.tgz", + "integrity": "sha512-C7AK2wn43GSaCUZ9do6Ksgi2g3mwFkMO3Cis96kzmgudoVaKyt62yNzJOktP0HDLb/iO2O0n2lBOzJgr6Q/cyg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.9.0", - "@typescript-eslint/types": "6.9.0", - "@typescript-eslint/typescript-estree": "6.9.0", - "@typescript-eslint/visitor-keys": "6.9.0", + "@typescript-eslint/scope-manager": "6.9.1", + "@typescript-eslint/types": "6.9.1", + "@typescript-eslint/typescript-estree": "6.9.1", + "@typescript-eslint/visitor-keys": "6.9.1", "debug": "^4.3.4" }, "engines": { @@ -2707,13 +2710,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.9.0.tgz", - "integrity": "sha512-1R8A9Mc39n4pCCz9o79qRO31HGNDvC7UhPhv26TovDsWPBDx+Sg3rOZdCELIA3ZmNoWAuxaMOT7aWtGRSYkQxw==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.9.1.tgz", + "integrity": "sha512-38IxvKB6NAne3g/+MyXMs2Cda/Sz+CEpmm+KLGEM8hx/CvnSRuw51i8ukfwB/B/sESdeTGet1NH1Wj7I0YXswg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.9.0", - "@typescript-eslint/visitor-keys": "6.9.0" + "@typescript-eslint/types": "6.9.1", + "@typescript-eslint/visitor-keys": "6.9.1" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -2724,9 +2727,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.9.0.tgz", - "integrity": "sha512-+KB0lbkpxBkBSiVCuQvduqMJy+I1FyDbdwSpM3IoBS7APl4Bu15lStPjgBIdykdRqQNYqYNMa8Kuidax6phaEw==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.9.1.tgz", + "integrity": "sha512-BUGslGOb14zUHOUmDB2FfT6SI1CcZEJYfF3qFwBeUrU6srJfzANonwRYHDpLBuzbq3HaoF2XL2hcr01c8f8OaQ==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -2737,13 +2740,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.9.0.tgz", - "integrity": "sha512-NJM2BnJFZBEAbCfBP00zONKXvMqihZCrmwCaik0UhLr0vAgb6oguXxLX1k00oQyD+vZZ+CJn3kocvv2yxm4awQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.9.1.tgz", + "integrity": "sha512-U+mUylTHfcqeO7mLWVQ5W/tMLXqVpRv61wm9ZtfE5egz7gtnmqVIw9ryh0mgIlkKk9rZLY3UHygsBSdB9/ftyw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.9.0", - "@typescript-eslint/visitor-keys": "6.9.0", + "@typescript-eslint/types": "6.9.1", + "@typescript-eslint/visitor-keys": "6.9.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2764,12 +2767,12 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.9.0.tgz", - "integrity": "sha512-dGtAfqjV6RFOtIP8I0B4ZTBRrlTT8NHHlZZSchQx3qReaoDeXhYM++M4So2AgFK9ZB0emRPA6JI1HkafzA2Ibg==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.9.1.tgz", + "integrity": "sha512-MUaPUe/QRLEffARsmNfmpghuQkW436DvESW+h+M52w0coICHRfD6Np9/K6PdACwnrq1HmuLl+cSPZaJmeVPkSw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.9.0", + "@typescript-eslint/types": "6.9.1", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -3365,9 +3368,9 @@ } }, "node_modules/async": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", - "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", "dev": true }, "node_modules/asynckit": { @@ -4161,15 +4164,15 @@ } }, "node_modules/conventional-changelog-angular": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-6.0.0.tgz", - "integrity": "sha512-6qLgrBF4gueoC7AFVHu51nHL9pF9FRjXrH+ceVf7WmAfH3gs+gEYOkvxhjMPjZu57I4AGUGoNTY8V7Hrgf1uqg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", "dev": true, "dependencies": { "compare-func": "^2.0.0" }, "engines": { - "node": ">=14" + "node": ">=16" } }, "node_modules/conventional-changelog-core": { @@ -5709,9 +5712,9 @@ } }, "node_modules/fs-extra/node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "engines": { "node": ">= 10.0.0" @@ -7412,9 +7415,9 @@ } }, "node_modules/jsonfile/node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "engines": { "node": ">= 10.0.0" @@ -7464,13 +7467,13 @@ } }, "node_modules/lerna": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/lerna/-/lerna-7.4.1.tgz", - "integrity": "sha512-c6sOO0dlJU689vStIsko+zjRdn2fJOWH8aNjePLNv2AubAdABKqfrDCpE2H/Q7+O80Duo68ZQtWYkUUk7hRWDw==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/lerna/-/lerna-7.4.2.tgz", + "integrity": "sha512-gxavfzHfJ4JL30OvMunmlm4Anw7d7Tq6tdVHzUukLdS9nWnxCN/QB21qR+VJYp5tcyXogHKbdUEGh6qmeyzxSA==", "dev": true, "dependencies": { - "@lerna/child-process": "7.4.1", - "@lerna/create": "7.4.1", + "@lerna/child-process": "7.4.2", + "@lerna/create": "7.4.2", "@npmcli/run-script": "6.0.2", "@nx/devkit": ">=16.5.1 < 17", "@octokit/plugin-enterprise-rest": "6.0.1", @@ -7480,7 +7483,7 @@ "clone-deep": "4.0.1", "cmd-shim": "6.0.1", "columnify": "1.6.0", - "conventional-changelog-angular": "6.0.0", + "conventional-changelog-angular": "7.0.0", "conventional-changelog-core": "5.0.1", "conventional-recommended-bump": "7.0.1", "cosmiconfig": "^8.2.0", @@ -13040,9 +13043,9 @@ } }, "node_modules/universal-user-agent": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", - "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", "dev": true }, "node_modules/universalify": { @@ -13746,13 +13749,13 @@ }, "packages/matter-node-ble.js": { "name": "@project-chip/matter-node-ble.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "ts-node": "^10.9.1", "typescript": "^5.2.2" }, @@ -13766,11 +13769,11 @@ }, "packages/matter-node-shell.js": { "name": "@project-chip/matter-node-shell.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { - "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter-node.js": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter-node.js": "0.6.1-alpha.0-20231106-8322fa6", "child_process": "^1.0.2", "readline": "^1.3.0", "yargs": "^17.7.2" @@ -13779,7 +13782,7 @@ "shell": "dist/cjs/app.js" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "typescript": "^5.2.2" }, "engines": { @@ -13789,17 +13792,17 @@ }, "packages/matter-node.js": { "name": "@project-chip/matter-node.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", "node-localstorage": "^3.0.5" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "@types/bn.js": "^5.1.3", "@types/bytebuffer": "^5.0.46", - "@types/node-localstorage": "^1.3.1", + "@types/node-localstorage": "^1.3.2", "bn.js": "^5.2.1", "ts-node": "^10.9.1", "typescript": "^5.2.2" @@ -13811,11 +13814,11 @@ }, "packages/matter-node.js-examples": { "name": "@project-chip/matter-node.js-examples", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { - "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter-node.js": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter-node.js": "0.6.1-alpha.0-20231106-8322fa6" }, "bin": { "matter-bridge": "dist/cjs/examples/BridgedDevicesNode.js", @@ -13825,7 +13828,7 @@ "matter-multidevice": "dist/cjs/examples/MultiDeviceNode.js" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "typescript": "^5.2.2" }, "engines": { @@ -13835,14 +13838,14 @@ }, "packages/matter.js": { "name": "@project-chip/matter.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { "bn.js": "^5.2.1", "elliptic": "^6.5.4" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "@types/bn.js": "^5.1.3", "@types/chai": "^4.3.9", "@types/elliptic": "^6.4.16", @@ -13854,7 +13857,7 @@ }, "tools": { "name": "@project-chip/matter.js-tools", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "license": "Apache-2.0", "dependencies": { "@npmcli/map-workspaces": "^3.0.4", @@ -13883,7 +13886,7 @@ "@types/glob": "^8.1.0", "@types/mocha": "^10.0.3", "@types/npmcli__map-workspaces": "^3.0.3", - "@types/yargs": "^17.0.28" + "@types/yargs": "^17.0.29" }, "optionalDependencies": { "v8-profiler-next": "^1.9.0" diff --git a/package.json b/package.json index 05ac0dda99..cab03f09d0 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,11 @@ }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.7.4", - "@typescript-eslint/parser": "^6.9.0", + "@typescript-eslint/parser": "^6.9.1", "eslint": "^8.51.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.0", - "lerna": "^7.4.1", + "lerna": "^7.4.2", "prettier": "^3.0.3", "prettier-plugin-organize-imports": "^3.2.3", "semver": "^7.5.4", diff --git a/packages/matter-node-ble.js/package.json b/packages/matter-node-ble.js/package.json index 35f7216634..c430bf7b95 100644 --- a/packages/matter-node-ble.js/package.json +++ b/packages/matter-node-ble.js/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter-node-ble.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Matter BLE support for node.js", "keywords": [ "iot", @@ -29,12 +29,12 @@ "build-doc": "typedoc --excludeExternals --plugin typedoc-plugin-missing-exports --plugin typedoc-plugin-markdown --tsconfig src/tsconfig.json" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "ts-node": "^10.9.1", "typescript": "^5.2.2" }, "dependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6" }, "optionalDependencies": { "@abandonware/bleno": "^0.6.1", diff --git a/packages/matter-node-ble.js/src/ble/BleScanner.ts b/packages/matter-node-ble.js/src/ble/BleScanner.ts index b26d1635dd..9bf2c8c76b 100644 --- a/packages/matter-node-ble.js/src/ble/BleScanner.ts +++ b/packages/matter-node-ble.js/src/ble/BleScanner.ts @@ -32,7 +32,14 @@ type CommissionableDeviceData = CommissionableDevice & { }; export class BleScanner implements Scanner { - private readonly recordWaiters = new Map void; timer: Timer }>(); + private readonly recordWaiters = new Map< + string, + { + resolver: () => void; + timer: Timer; + resolveOnUpdatedRecords: boolean; + } + >(); private readonly discoveredMatterDevices = new Map(); constructor(private readonly nobleClient: NobleBleClient) { @@ -53,22 +60,27 @@ export class BleScanner implements Scanner { * Registers a deferred promise for a specific queryId together with a timeout and return the promise. * The promise will be resolved when the timer runs out latest. */ - private async registerWaiterPromise(queryId: string, timeoutSeconds: number) { + private async registerWaiterPromise(queryId: string, timeoutSeconds: number, resolveOnUpdatedRecords = true) { const { promise, resolver } = createPromise(); const timer = Time.getTimer(timeoutSeconds * 1000, () => this.finishWaiter(queryId, true)).start(); - this.recordWaiters.set(queryId, { resolver, timer }); - logger.debug(`Registered waiter for query ${queryId} with timeout ${timeoutSeconds} seconds`); - return { promise }; + this.recordWaiters.set(queryId, { resolver, timer, resolveOnUpdatedRecords }); + logger.debug( + `Registered waiter for query ${queryId} with timeout ${timeoutSeconds} seconds${ + resolveOnUpdatedRecords ? "" : " (not resolving on updated records)" + }`, + ); + await promise; } /** * Remove a waiter promise for a specific queryId and stop the connected timer. If required also resolve the * promise. */ - private finishWaiter(queryId: string, resolvePromise = false) { + private finishWaiter(queryId: string, resolvePromise: boolean, isUpdatedRecord = false) { const waiter = this.recordWaiters.get(queryId); if (waiter === undefined) return; - const { timer, resolver } = waiter; + const { timer, resolver, resolveOnUpdatedRecords } = waiter; + if (isUpdatedRecord && !resolveOnUpdatedRecords) return; logger.debug(`Finishing waiter for query ${queryId}, resolving: ${resolvePromise}`); timer.stop(); if (resolvePromise) { @@ -77,6 +89,11 @@ export class BleScanner implements Scanner { this.recordWaiters.delete(queryId); } + cancelCommissionableDeviceDiscovery(identifier: CommissionableDeviceIdentifiers) { + const queryKey = this.buildCommissionableQueryIdentifier(identifier); + this.finishWaiter(queryKey, true); + } + private handleDiscoveredDevice(peripheral: Peripheral, manufacturerServiceData: ByteArray) { logger.debug(`Discovered device ${peripheral.address} ${manufacturerServiceData?.toHex()}`); @@ -85,6 +102,7 @@ export class BleScanner implements Scanner { BtpCodec.decodeBleAdvertisementServiceData(manufacturerServiceData); const commissionableDevice: CommissionableDeviceData = { + deviceIdentifier: peripheral.address, D: discriminator, SD: (discriminator >> 8) & 0x0f, VP: `${vendorId}+${productId}`, @@ -93,6 +111,8 @@ export class BleScanner implements Scanner { }; logger.debug(`Discovered device ${peripheral.address} data: ${JSON.stringify(commissionableDevice)}`); + const deviceExisting = this.discoveredMatterDevices.has(peripheral.address); + this.discoveredMatterDevices.set(peripheral.address, { deviceData: commissionableDevice, peripheral: peripheral, @@ -101,7 +121,7 @@ export class BleScanner implements Scanner { const queryKey = this.findCommissionableQueryIdentifier(commissionableDevice); if (queryKey !== undefined) { - this.finishWaiter(queryKey, true); + this.finishWaiter(queryKey, true, deviceExisting); } } catch (error) { logger.debug(`Seems not to be a valid Matter device: Failed to decode device data: ${error}`); @@ -205,11 +225,9 @@ export class BleScanner implements Scanner { let storedRecords = this.getCommissionableDevices(identifier); if (storedRecords.length === 0) { const queryKey = this.buildCommissionableQueryIdentifier(identifier); - const { promise } = await this.registerWaiterPromise(queryKey, timeoutSeconds); await this.nobleClient.startScanning(); - - await promise; + await this.registerWaiterPromise(queryKey, timeoutSeconds); storedRecords = this.getCommissionableDevices(identifier); await this.nobleClient.stopScanning(); @@ -217,12 +235,44 @@ export class BleScanner implements Scanner { return storedRecords.map(({ deviceData }) => deviceData); } + async findCommissionableDevicesContinuously( + identifier: CommissionableDeviceIdentifiers, + callback: (device: CommissionableDevice) => void, + timeoutSeconds = 60, + ): Promise { + const discoveredDevices = new Set(); + + const discoveryEndTime = Time.nowMs() + timeoutSeconds * 1000; + const queryKey = this.buildCommissionableQueryIdentifier(identifier); + await this.nobleClient.startScanning(); + + while (true) { + this.getCommissionableDevices(identifier).forEach(({ deviceData }) => { + const { deviceIdentifier } = deviceData; + if (!discoveredDevices.has(deviceIdentifier)) { + discoveredDevices.add(deviceIdentifier); + callback(deviceData); + } + }); + + const remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000); + if (remainingTime <= 0) { + break; + } + await this.registerWaiterPromise(queryKey, remainingTime, false); + } + await this.nobleClient.stopScanning(); + return this.getCommissionableDevices(identifier).map(({ deviceData }) => deviceData); + } + getDiscoveredCommissionableDevices(identifier: CommissionableDeviceIdentifiers): CommissionableDevice[] { return this.getCommissionableDevices(identifier).map(({ deviceData }) => deviceData); } close(): void { void this.nobleClient.stopScanning(); - [...this.recordWaiters.keys()].forEach(queryId => this.finishWaiter(queryId, true)); + [...this.recordWaiters.keys()].forEach(queryId => + this.finishWaiter(queryId, !!this.recordWaiters.get(queryId)?.timer), + ); } } diff --git a/packages/matter-node-shell.js/package.json b/packages/matter-node-shell.js/package.json index 41a2253d7c..49e54002c6 100644 --- a/packages/matter-node-shell.js/package.json +++ b/packages/matter-node-shell.js/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter-node-shell.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Shell app for Matter controller", "keywords": [ "iot", @@ -32,12 +32,12 @@ "shell": "./dist/cjs/app.js" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "typescript": "^5.2.2" }, "dependencies": { - "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter-node.js": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter-node.js": "0.6.1-alpha.0-20231106-8322fa6", "child_process": "^1.0.2", "readline": "^1.3.0", "yargs": "^17.7.2" diff --git a/packages/matter-node-shell.js/src/MatterNode.ts b/packages/matter-node-shell.js/src/MatterNode.ts index c0553649fb..946185ca07 100644 --- a/packages/matter-node-shell.js/src/MatterNode.ts +++ b/packages/matter-node-shell.js/src/MatterNode.ts @@ -1,6 +1,9 @@ /** - * Import needed modules from @project-chip/matter-node.js + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ + // Include this first to auto-register Crypto, Network and Time Node.js implementations import { CommissioningController, MatterServer } from "@project-chip/matter-node.js"; @@ -21,7 +24,7 @@ export class MatterNode { private storageContext?: StorageContext; commissioningController?: CommissioningController; - private matterDevice?: MatterServer; + private matterController?: MatterServer; constructor( private readonly nodeNum: number, @@ -55,7 +58,7 @@ export class MatterNode { } async close() { - await this.matterDevice?.close(); + await this.matterController?.close(); this.closeStorage(); } @@ -70,7 +73,7 @@ export class MatterNode { if (this.storageManager === undefined) { throw new Error("StorageManager not initialized"); // Should never happen } - if (this.matterDevice !== undefined) { + if (this.matterController !== undefined) { return; } logger.info(`matter.js shell controller started for node ${this.nodeNum}`); @@ -88,11 +91,11 @@ export class MatterNode { * are called. */ - this.matterDevice = new MatterServer(this.storageManager, { mdnsInterface: this.netInterface }); + this.matterController = new MatterServer(this.storageManager, { mdnsInterface: this.netInterface }); this.commissioningController = new CommissioningController({ autoConnect: false, }); - this.matterDevice.addCommissioningController(this.commissioningController); + this.matterController.addCommissioningController(this.commissioningController); /** * Start the Matter Server @@ -101,7 +104,7 @@ export class MatterNode { * CommissioningServer node then this command also starts the announcement of the device into the network. */ - await this.matterDevice.start(); + await this.matterController.start(); } async connectAndGetNodes(nodeIdStr?: string) { diff --git a/packages/matter-node-shell.js/src/app.ts b/packages/matter-node-shell.js/src/app.ts index 57b665f5d8..a7cf1d8afa 100644 --- a/packages/matter-node-shell.js/src/app.ts +++ b/packages/matter-node-shell.js/src/app.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { BleNode } from "@project-chip/matter-node-ble.js/ble"; @@ -90,8 +80,9 @@ async function main() { const { nodeNum, ble, nodeType, factoryReset, netInterface } = argv; - const theNode = new MatterNode(nodeNum, netInterface); + theNode = new MatterNode(nodeNum, netInterface); await theNode.initialize(factoryReset); + const theShell = new Shell(theNode, PROMPT); if (ble) { diff --git a/packages/matter-node-shell.js/src/shell/Shell.ts b/packages/matter-node-shell.js/src/shell/Shell.ts index 89c7e036fc..fe631571ad 100644 --- a/packages/matter-node-shell.js/src/shell/Shell.ts +++ b/packages/matter-node-shell.js/src/shell/Shell.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { MatterError } from "@project-chip/matter-node.js/common"; @@ -22,6 +12,7 @@ import { MatterNode } from "../MatterNode.js"; import { exit } from "../app"; import cmdCommission from "./cmd_commission.js"; import cmdConfig from "./cmd_config.js"; +import cmdDiscover from "./cmd_discover.js"; import cmdIdentify from "./cmd_identify.js"; import cmdLock from "./cmd_lock.js"; import cmdNodes from "./cmd_nodes.js"; @@ -55,7 +46,6 @@ export class Shell { * * @param {MatterNode} theNode MatterNode object to use for all commands. * @param {string} prompt Prompt string to use for each command line. - * @param {Array} commandList Array of JSON commands dispatch structures. */ constructor( public theNode: MatterNode, @@ -77,8 +67,12 @@ export class Shell { }); }) .on("close", () => { - process.stdout.write("goodbye\n"); - process.exit(0); + exit() + .then(() => process.exit(0)) + .catch(e => { + process.stderr.write(`Close error: ${e}\n`); + process.exit(1); + }); }); this.readline.prompt(); @@ -102,6 +96,7 @@ export class Shell { cmdOnOff(this.theNode), cmdSubscribe(this.theNode), cmdIdentify(this.theNode), + cmdDiscover(this.theNode), exitCommand(), ]) .command({ @@ -124,6 +119,8 @@ export class Shell { if (argv.unhandled) { process.stderr.write(`Unknown command: ${line}\n`); yargsInstance.showHelp(); + } else { + console.log("Done."); } } catch (error) { process.stderr.write(`Error happened during command: ${error}\n`); diff --git a/packages/matter-node-shell.js/src/shell/cmd_commission.ts b/packages/matter-node-shell.js/src/shell/cmd_commission.ts index ee912a9dbd..626adee177 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_commission.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_commission.ts @@ -1,25 +1,17 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { NodeCommissioningOptions } from "@project-chip/matter-node.js"; import { BasicInformationCluster, DescriptorCluster, GeneralCommissioning } from "@project-chip/matter-node.js/cluster"; import { NodeId } from "@project-chip/matter-node.js/datatype"; +import { Logger } from "@project-chip/matter-node.js/log"; import { ManualPairingCodeCodec, QrCode } from "@project-chip/matter-node.js/schema"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode"; +import { createDiagnosticCallbacks } from "./cmd_nodes"; export default function commands(theNode: MatterNode) { return { @@ -54,7 +46,14 @@ export default function commands(theNode: MatterNode) { }); }, async argv => { - const { pairingCode, nodeId: nodeIdStr, ipPort, ip, ble = false } = argv; + const { + pairingCode, + nodeId: nodeIdStr, + ipPort, + ip, + ble = false, + instanceId, + } = argv; let { setupPinCode, discriminator, shortDiscriminator } = argv; if (typeof pairingCode === "string") { @@ -80,7 +79,9 @@ export default function commands(theNode: MatterNode) { ? { ip, port: ipPort, type: "udp" } : undefined, identifierData: - discriminator !== undefined + instanceId !== undefined + ? { instanceId } + : discriminator !== undefined ? { longDiscriminator: discriminator } : shortDiscriminator !== undefined ? { shortDiscriminator } @@ -91,6 +92,7 @@ export default function commands(theNode: MatterNode) { }, }, passcode: setupPinCode, + ...createDiagnosticCallbacks(), } as NodeCommissioningOptions; options.commissioning = { @@ -99,7 +101,7 @@ export default function commands(theNode: MatterNode) { regulatoryCountryCode: "XX", }; - console.log(JSON.stringify(options)); + console.log(Logger.toJSON(options)); if (theNode.Store.has("WiFiSsid") && theNode.Store.has("WiFiPassword")) { options.commissioning.wifiNetwork = { @@ -158,6 +160,11 @@ export default function commands(theNode: MatterNode) { default: 20202021, type: "number", }, + instanceId: { + alias: "i", + describe: "instance id", + type: "string", + }, discriminator: { alias: "d", description: "Long discriminator", @@ -234,6 +241,23 @@ export default function commands(theNode: MatterNode) { ); console.log(`Manual pairing code: ${manualPairingCode}`); }, + ) + .command( + "unpair ", + "Unpair/Decommission a node", + yargs => { + return yargs.positional("node-id", { + describe: "node id", + type: "string", + demandOption: true, + }); + }, + async argv => { + await theNode.start(); + const { nodeId } = argv; + const node = (await theNode.connectAndGetNodes(nodeId))[0]; + await node.decommission(); + }, ), handler: async (argv: any) => { argv.unhandled = true; diff --git a/packages/matter-node-shell.js/src/shell/cmd_config.ts b/packages/matter-node-shell.js/src/shell/cmd_config.ts index a2766cf8d8..4ee535d214 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_config.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_config.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { Logger } from "@project-chip/matter-node.js/log"; diff --git a/packages/matter-node-shell.js/src/shell/cmd_discover.ts b/packages/matter-node-shell.js/src/shell/cmd_discover.ts new file mode 100644 index 0000000000..c53d6dc229 --- /dev/null +++ b/packages/matter-node-shell.js/src/shell/cmd_discover.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VendorId } from "@project-chip/matter-node.js/datatype"; +import { Logger } from "@project-chip/matter-node.js/log"; +import { ManualPairingCodeCodec } from "@project-chip/matter-node.js/schema"; +import { CommissionableDeviceIdentifiers } from "@project-chip/matter.js/common"; +import type { Argv } from "yargs"; +import { MatterNode } from "../MatterNode"; + +export default function commands(theNode: MatterNode) { + return { + command: "discover", + describe: "Handle device discovery", + builder: (yargs: Argv) => + yargs + // Pair + .command( + "commissionable [timeout-seconds]", + "Discover commissionable devices", + () => { + return yargs + .positional("timeout-seconds", { + describe: "Discovery timeout in seconds", + default: 900, + type: "number", + }) + .options({ + pairingCode: { + describe: "pairing code", + default: undefined, + type: "string", + }, + discriminator: { + alias: "d", + description: "Long discriminator", + default: undefined, + type: "number", + }, + shortDiscriminator: { + alias: "s", + description: "Short discriminator", + default: undefined, + type: "number", + }, + vendorId: { + alias: "v", + description: "Vendor ID", + default: undefined, + type: "number", + }, + productId: { + alias: "p", + description: "Product ID", + default: undefined, + type: "number", + }, + deviceType: { + alias: "t", + description: "Device Type", + default: undefined, + type: "number", + }, + ble: { + alias: "b", + description: "Also discover over BLE", + default: false, + type: "boolean", + }, + }); + }, + async argv => { + const { ble = false, pairingCode, vendorId, productId, deviceType, timeoutSeconds } = argv; + let { discriminator, shortDiscriminator } = argv; + + if (typeof pairingCode === "string") { + const { shortDiscriminator: pairingCodeShortDiscriminator } = + ManualPairingCodeCodec.decode(pairingCode); + shortDiscriminator = pairingCodeShortDiscriminator; + discriminator = undefined; + } + + await theNode.start(); + if (theNode.commissioningController === undefined) { + throw new Error("CommissioningController not initialized"); + } + + const identifierData: CommissionableDeviceIdentifiers = + discriminator !== undefined + ? { longDiscriminator: discriminator } + : shortDiscriminator !== undefined + ? { shortDiscriminator } + : vendorId !== undefined + ? { vendorId: VendorId(vendorId) } + : productId !== undefined + ? { productId } + : deviceType !== undefined + ? { deviceType } + : {}; + + console.log( + `Discover devices with identifier ${Logger.toJSON( + identifierData, + )} for ${timeoutSeconds} seconds.`, + ); + + const results = await theNode.commissioningController.discoverCommissionableDevices( + identifierData, + { + ble, + onIpNetwork: true, + }, + device => console.log(`Discovered device ${Logger.toJSON(device)}`), + timeoutSeconds, + ); + + console.log(`Discovered ${results.length} devices`, results); + }, + ), + handler: async (argv: any) => { + argv.unhandled = true; + }, + }; +} diff --git a/packages/matter-node-shell.js/src/shell/cmd_identify.ts b/packages/matter-node-shell.js/src/shell/cmd_identify.ts index e2e4bf6cad..4410e015cf 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_identify.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_identify.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { IdentifyCluster } from "@project-chip/matter-node.js/cluster"; diff --git a/packages/matter-node-shell.js/src/shell/cmd_lock.ts b/packages/matter-node-shell.js/src/shell/cmd_lock.ts index fc3aa21fcd..4394f33ea7 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_lock.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_lock.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import type { Argv } from "yargs"; diff --git a/packages/matter-node-shell.js/src/shell/cmd_nodes.ts b/packages/matter-node-shell.js/src/shell/cmd_nodes.ts index 867a9acb8b..28a803a298 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_nodes.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_nodes.ts @@ -1,22 +1,53 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ +import { NodeId } from "@project-chip/matter-node.js/datatype"; +import { CommissioningControllerNodeOptions, NodeStateInformation } from "@project-chip/matter-node.js/device"; +import { Logger } from "@project-chip/matter-node.js/log"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode"; +export function createDiagnosticCallbacks(): Partial { + return { + attributeChangedCallback: (peerNodeId, { path: { nodeId, clusterId, endpointId, attributeName }, value }) => + console.log( + `attributeChangedCallback ${peerNodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Logger.toJSON( + value, + )}`, + ), + eventTriggeredCallback: (peerNodeId, { path: { nodeId, clusterId, endpointId, eventName }, events }) => + console.log( + `eventTriggeredCallback ${peerNodeId}: Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Logger.toJSON( + events, + )}`, + ), + stateInformationCallback: (peerNodeId, info) => { + switch (info) { + case NodeStateInformation.Connected: + console.log(`stateInformationCallback Node ${peerNodeId} connected`); + break; + case NodeStateInformation.Disconnected: + console.log(`stateInformationCallback Node ${peerNodeId} disconnected`); + break; + case NodeStateInformation.Reconnecting: + console.log(`stateInformationCallback Node ${peerNodeId} reconnecting`); + break; + case NodeStateInformation.WaitingForDeviceDiscovery: + console.log( + `stateInformationCallback Node ${peerNodeId} waiting that device gets discovered again`, + ); + break; + case NodeStateInformation.StructureChanged: + console.log(`stateInformationCallback Node ${peerNodeId} structure changed`); + break; + } + }, + }; +} + export default function commands(theNode: MatterNode) { return { command: ["nodes", "node"], @@ -72,7 +103,95 @@ export default function commands(theNode: MatterNode) { const node = (await theNode.connectAndGetNodes(nodeId))[0]; console.log("Logging structure of Node ", node.nodeId.toString()); - node.logStructure(); + node.logStructure({}); + }, + ) + .command( + "connect [min-subscription-interval] [max-subscription-interval]", + "Connects to one or all cmmissioned nodes", + yargs => { + return yargs + .positional("node-id", { + describe: "node id to connect. Use 'all' to connect to all nodes.", + default: undefined, + type: "string", + demandOption: true, + }) + .positional("min-subscription-interval", { + describe: + "Minimum subscription interval in seconds. If set then the node is subscribed to all attributes and events.", + type: "number", + }) + .positional("max-subscription-interval", { + describe: + "Maximum subscription interval in seconds. If minimum interval is set and this not this is set to 30 seconds.", + type: "number", + }); + }, + async argv => { + const { nodeId: nodeIdStr, maxSubscriptionInterval, minSubscriptionInterval } = argv; + await theNode.start(); + if (theNode.commissioningController === undefined) { + throw new Error("CommissioningController not initialized"); + } + let nodeIds = theNode.commissioningController.getCommissionedNodes(); + if (nodeIdStr !== "all") { + const cmdNodeId = NodeId(BigInt(nodeIdStr)); + nodeIds = nodeIds.filter(nodeId => nodeId === cmdNodeId); + if (!nodeIds.length) { + throw new Error(`Node ${nodeIdStr} not commissioned`); + } + } + + const autoSubscribe = minSubscriptionInterval !== undefined; + + for (const nodeIdToProcess of nodeIds) { + await theNode.commissioningController.connectNode(nodeIdToProcess, { + autoSubscribe, + subscribeMinIntervalFloorSeconds: autoSubscribe ? minSubscriptionInterval : undefined, + subscribeMaxIntervalCeilingSeconds: autoSubscribe + ? maxSubscriptionInterval ?? 30 + : undefined, + ...createDiagnosticCallbacks(), + }); + } + }, + ) + .command( + "disconnect ", + "Disconnects from one or all nodes", + yargs => { + return yargs.positional("node-id", { + describe: "node id to disconnect. Use 'all' to disconnect from all nodes.", + default: undefined, + type: "string", + demandOption: true, + }); + }, + async argv => { + const { nodeId: nodeIdStr } = argv; + if (theNode.commissioningController === undefined) { + console.log("Controller not initialized, nothing to disconnect."); + return; + } + + let nodeIds = theNode.commissioningController.getCommissionedNodes(); + if (nodeIdStr !== "all") { + const cmdNodeId = NodeId(BigInt(nodeIdStr)); + nodeIds = nodeIds.filter(nodeId => nodeId === cmdNodeId); + if (!nodeIds.length) { + throw new Error(`Node ${nodeIdStr} not commissioned`); + } + } + + for (const nodeIdToProcess of nodeIds) { + const node = theNode.commissioningController.getConnectedNode(nodeIdToProcess); + if (node === undefined) { + console.log(`Node ${nodeIdToProcess} not connected`); + continue; + } + await node.disconnect(); + } }, ), handler: async (argv: any) => { diff --git a/packages/matter-node-shell.js/src/shell/cmd_onoff.ts b/packages/matter-node-shell.js/src/shell/cmd_onoff.ts index 8161d1052d..afde30d2f4 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_onoff.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_onoff.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { OnOffCluster } from "@project-chip/matter-node.js/cluster"; diff --git a/packages/matter-node-shell.js/src/shell/cmd_session.ts b/packages/matter-node-shell.js/src/shell/cmd_session.ts index af9388f67d..a2a4ba426d 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_session.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_session.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { MatterNode } from "../MatterNode"; diff --git a/packages/matter-node-shell.js/src/shell/cmd_subscribe.ts b/packages/matter-node-shell.js/src/shell/cmd_subscribe.ts index 9c796d4b69..532f4f248f 100644 --- a/packages/matter-node-shell.js/src/shell/cmd_subscribe.ts +++ b/packages/matter-node-shell.js/src/shell/cmd_subscribe.ts @@ -1,17 +1,7 @@ /** - * Copyright 2022 Project CHIP Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * @license + * Copyright 2022-2023 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 */ import { Logger } from "@project-chip/matter-node.js/log"; @@ -45,13 +35,13 @@ export default function commands(theNode: MatterNode) { value, }) => console.log( - `Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Logger.toJSON( + `${nodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Logger.toJSON( value, )}`, ), eventTriggeredCallback: ({ path: { nodeId, clusterId, endpointId, eventName }, events }) => console.log( - `Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Logger.toJSON( + `${nodeId} Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Logger.toJSON( events, )}`, ), diff --git a/packages/matter-node.js-examples/package.json b/packages/matter-node.js-examples/package.json index f51b344998..1650d548fe 100644 --- a/packages/matter-node.js-examples/package.json +++ b/packages/matter-node.js-examples/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter-node.js-examples", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "CLI/Reference implementation scripts for Matter protocol for node.js", "keywords": [ "iot", @@ -43,12 +43,12 @@ "matter-controller": "./dist/cjs/examples/ControllerNode.js" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "typescript": "^5.2.2" }, "dependencies": { - "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231102-e09231e", - "@project-chip/matter-node.js": "0.6.1-alpha.0-20231102-e09231e" + "@project-chip/matter-node-ble.js": "0.6.1-alpha.0-20231106-8322fa6", + "@project-chip/matter-node.js": "0.6.1-alpha.0-20231106-8322fa6" }, "engines": { "_comment": "For Crypto.hkdf support", diff --git a/packages/matter-node.js-examples/src/examples/ControllerNode.ts b/packages/matter-node.js-examples/src/examples/ControllerNode.ts index ee1d4761c0..0590501987 100644 --- a/packages/matter-node.js-examples/src/examples/ControllerNode.ts +++ b/packages/matter-node.js-examples/src/examples/ControllerNode.ts @@ -27,6 +27,7 @@ import { OnOffCluster, } from "@project-chip/matter-node.js/cluster"; import { NodeId } from "@project-chip/matter-node.js/datatype"; +import { NodeStateInformation } from "@project-chip/matter-node.js/device"; import { Format, Level, Logger } from "@project-chip/matter-node.js/log"; import { CommissioningOptions } from "@project-chip/matter-node.js/protocol"; import { ManualPairingCodeCodec } from "@project-chip/matter-node.js/schema"; @@ -222,7 +223,44 @@ class ControllerNode { throw new Error(`Node ${nodeId} not found in commissioned nodes`); } - const node = await commissioningController.connectNode(nodeId); + const node = await commissioningController.connectNode(nodeId, { + attributeChangedCallback: ( + peerNodeId, + { path: { nodeId, clusterId, endpointId, attributeName }, value }, + ) => + console.log( + `attributeChangedCallback ${peerNodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Logger.toJSON( + value, + )}`, + ), + eventTriggeredCallback: (peerNodeId, { path: { nodeId, clusterId, endpointId, eventName }, events }) => + console.log( + `eventTriggeredCallback ${peerNodeId}: Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Logger.toJSON( + events, + )}`, + ), + stateInformationCallback: (peerNodeId, info) => { + switch (info) { + case NodeStateInformation.Connected: + console.log(`stateInformationCallback ${peerNodeId}: Node ${nodeId} connected`); + break; + case NodeStateInformation.Disconnected: + console.log(`stateInformationCallback ${peerNodeId}: Node ${nodeId} disconnected`); + break; + case NodeStateInformation.Reconnecting: + console.log(`stateInformationCallback ${peerNodeId}: Node ${nodeId} reconnecting`); + break; + case NodeStateInformation.WaitingForDeviceDiscovery: + console.log( + `stateInformationCallback ${peerNodeId}: Node ${nodeId} waiting for device discovery`, + ); + break; + case NodeStateInformation.StructureChanged: + console.log(`stateInformationCallback ${peerNodeId}: Node ${nodeId} structure changed`); + break; + } + }, + }); // Important: This is a temporary API to proof the methods working and this will change soon and is NOT stable! // It is provided to proof the concept diff --git a/packages/matter-node.js/package.json b/packages/matter-node.js/package.json index 8da9f0e1de..ad6d910916 100644 --- a/packages/matter-node.js/package.json +++ b/packages/matter-node.js/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter-node.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Matter protocol for node.js", "keywords": [ "iot", @@ -31,16 +31,16 @@ "test": "matter-test" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "@types/bn.js": "^5.1.3", "@types/bytebuffer": "^5.0.46", - "@types/node-localstorage": "^1.3.1", + "@types/node-localstorage": "^1.3.2", "bn.js": "^5.2.1", "ts-node": "^10.9.1", "typescript": "^5.2.2" }, "dependencies": { - "@project-chip/matter.js": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js": "0.6.1-alpha.0-20231106-8322fa6", "node-localstorage": "^3.0.5" }, "engines": { diff --git a/packages/matter-node.js/test/IntegrationTest.ts b/packages/matter-node.js/test/IntegrationTest.ts index cd46a413d7..5f4c4002a7 100644 --- a/packages/matter-node.js/test/IntegrationTest.ts +++ b/packages/matter-node.js/test/IntegrationTest.ts @@ -32,7 +32,7 @@ import { NodeId, VendorId, } from "@project-chip/matter.js/datatype"; -import { OnOffLightDevice } from "@project-chip/matter.js/device"; +import { NodeStateInformation, OnOffLightDevice } from "@project-chip/matter.js/device"; import { FabricJsonObject } from "@project-chip/matter.js/fabric"; import { DecodedEventData, InteractionClientMessenger } from "@project-chip/matter.js/interaction"; import { MdnsBroadcaster, MdnsScanner } from "@project-chip/matter.js/mdns"; @@ -87,6 +87,21 @@ describe("Integration Test", () => { const commissioningChangedCallsServer2 = new Array<{ fabricIndex: FabricIndex; time: number }>(); const sessionChangedCallsServer = new Array<{ fabricIndex: FabricIndex; time: number }>(); const sessionChangedCallsServer2 = new Array<{ fabricIndex: FabricIndex; time: number }>(); + const nodeStateChangesController1Node1 = new Array<{ + nodeId: NodeId; + nodeState: NodeStateInformation; + time: number; + }>(); + const nodeStateChangesController1Node2 = new Array<{ + nodeId: NodeId; + nodeState: NodeStateInformation; + time: number; + }>(); + const nodeStateChangesController2Node1 = new Array<{ + nodeId: NodeId; + nodeState: NodeStateInformation; + time: number; + }>(); before(async () => { MockTime.reset(TIME_START); @@ -267,6 +282,8 @@ describe("Integration Test", () => { regulatoryCountryCode: "DE", }, passcode: setupPin, + stateInformationCallback: (nodeId: NodeId, nodeState: NodeStateInformation) => + nodeStateChangesController1Node1.push({ nodeId, nodeState, time: MockTime.nowMs() }), }); Time.get = () => mockTimeInstance; @@ -285,6 +302,10 @@ describe("Integration Test", () => { assert.ok(sessionInfo[0].fabric); assert.equal(sessionInfo[0].fabric.fabricIndex, FabricIndex(1)); assert.equal(sessionInfo[0].nodeId, node.nodeId); + + assert.deepEqual(nodeStateChangesController1Node1.length, 1); + assert.equal(nodeStateChangesController1Node1[0].nodeId, node.nodeId); + assert.equal(nodeStateChangesController1Node1[0].nodeState, NodeStateInformation.Connected); }); it("We can connect to the new commissioned device", async () => { @@ -300,6 +321,8 @@ describe("Integration Test", () => { assert.ok(sessionInfo[0].fabric); assert.equal(sessionInfo[0].fabric.fabricIndex, FabricIndex(1)); assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 0); + + assert.deepEqual(nodeStateChangesController1Node1.length, 1); // no new entry, stay connected }); it("Subscribe to all Attributes and bind updates to them", async () => { @@ -1074,7 +1097,7 @@ describe("Integration Test", () => { it("Check callback info", async () => { assert.equal(commissioningChangedCallsServer.length, 1); assert.ok(sessionChangedCallsServer.length >= 6); // not 100% accurate because of MockTime and not 100% finished responses and stuff like that - assert.equal(sessionChangedCallsServer[4].fabricIndex, FabricIndex(1)); + assert.equal(sessionChangedCallsServer[sessionChangedCallsServer.length - 1].fabricIndex, FabricIndex(1)); const sessionInfo = commissioningServer.getActiveSessionInformation(); assert.equal(sessionInfo.length, 1); assert.ok(sessionInfo[0].fabric); @@ -1244,6 +1267,8 @@ describe("Integration Test", () => { regulatoryCountryCode: "DE", }, passcode: setupPin2, + stateInformationCallback: (nodeId: NodeId, nodeState: NodeStateInformation) => + nodeStateChangesController1Node2.push({ nodeId, nodeState, time: MockTime.nowMs() }), }); Time.get = () => mockTimeInstance; @@ -1257,6 +1282,10 @@ describe("Integration Test", () => { assert.equal(sessionInfo.length, 1); assert.ok(sessionInfo[0].fabric); assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 0); + + assert.equal(nodeStateChangesController1Node2.length, 1); + assert.equal(nodeStateChangesController1Node2[0].nodeId, node.nodeId); + assert.equal(nodeStateChangesController1Node2[0].nodeState, NodeStateInformation.Connected); }); it("We can connect to the new commissioned device", async () => { @@ -1271,6 +1300,8 @@ describe("Integration Test", () => { assert.equal(sessionInfo.length, 1); assert.ok(sessionInfo[0].fabric); assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 0); + + assert.equal(nodeStateChangesController1Node2[0].nodeState, NodeStateInformation.Connected); }); it("Subscribe to all Attributes and bind updates to them for second device", async () => { @@ -1440,17 +1471,22 @@ describe("Integration Test", () => { regulatoryCountryCode: "DE", }, passcode, + stateInformationCallback: (nodeId: NodeId, nodeState: NodeStateInformation) => + nodeStateChangesController2Node1.push({ nodeId, nodeState, time: MockTime.nowMs() }), }), ); assert.equal(commissioningChangedCallsServer.length, 2); assert.ok(sessionChangedCallsServer.length >= 7); - assert.equal(sessionChangedCallsServer[7].fabricIndex, FabricIndex(2)); + assert.equal(sessionChangedCallsServer[sessionChangedCallsServer.length - 1].fabricIndex, FabricIndex(2)); const sessionInfo = commissioningServer.getActiveSessionInformation(); assert.equal(sessionInfo.length, 2); assert.ok(sessionInfo[1].fabric); assert.equal(sessionInfo[1].numberOfActiveSubscriptions, 0); assert.equal(commissioningChangedCallsServer2.length, 1); + + assert.equal(nodeStateChangesController2Node1.length, 1); + assert.equal(nodeStateChangesController2Node1[0].nodeState, NodeStateInformation.Connected); }).timeout(10_000); it("verify that the server storage got updated", async () => { @@ -1561,6 +1597,9 @@ describe("Integration Test", () => { assert.equal(commissioningChangedCallsServer.length, 3); assert.equal(commissioningChangedCallsServer[2].fabricIndex, FabricIndex(1)); assert.equal(commissioningChangedCallsServer2.length, 1); + + assert.equal(nodeStateChangesController1Node1.length, 2); + assert.equal(nodeStateChangesController1Node1[1].nodeState, NodeStateInformation.Disconnected); }); it("read and remove second node by removing fabric from device unplanned and doing factory reset", async () => { @@ -1606,6 +1645,10 @@ describe("Integration Test", () => { assert.equal(commissioningController.getCommissionedNodes().length, 0); assert.equal(commissioningChangedCallsServer2.length, 2); assert.equal(commissioningChangedCallsServer2[1].fabricIndex, FabricIndex(1)); + + assert.equal(nodeStateChangesController1Node2.length, 3); + assert.equal(nodeStateChangesController1Node2[1].nodeState, NodeStateInformation.Reconnecting); + assert.equal(nodeStateChangesController1Node2[2].nodeState, NodeStateInformation.Disconnected); }).timeout(30_000); it("controller storage is updated for removed nodes", async () => { diff --git a/packages/matter.js/package.json b/packages/matter.js/package.json index de93018d8a..317a1f30a1 100644 --- a/packages/matter.js/package.json +++ b/packages/matter.js/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter.js", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Matter protocol in pure js", "keywords": [ "iot", @@ -36,7 +36,7 @@ "elliptic": "^6.5.4" }, "devDependencies": { - "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231102-e09231e", + "@project-chip/matter.js-tools": "0.6.1-alpha.0-20231106-8322fa6", "@types/bn.js": "^5.1.3", "@types/chai": "^4.3.9", "@types/elliptic": "^6.4.16", diff --git a/packages/matter.js/src/CommissioningController.ts b/packages/matter.js/src/CommissioningController.ts index 951f636da1..0912f3a691 100644 --- a/packages/matter.js/src/CommissioningController.ts +++ b/packages/matter.js/src/CommissioningController.ts @@ -3,8 +3,10 @@ * Copyright 2022 The matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ +import { MatterController } from "./MatterController.js"; +import { MatterNode } from "./MatterNode.js"; import { ImplementationError } from "./common/MatterError.js"; -import { CommissionableDeviceIdentifiers } from "./common/Scanner.js"; +import { CommissionableDevice, CommissionableDeviceIdentifiers } from "./common/Scanner.js"; import { ServerAddress } from "./common/ServerAddress.js"; import { FabricId } from "./datatype/FabricId.js"; import { FabricIndex } from "./datatype/FabricIndex.js"; @@ -12,12 +14,11 @@ import { NodeId } from "./datatype/NodeId.js"; import { VendorId } from "./datatype/VendorId.js"; import { CommissioningControllerNodeOptions, PairedNode } from "./device/PairedNode.js"; import { Logger } from "./log/Logger.js"; -import { MatterController } from "./MatterController.js"; -import { MatterNode } from "./MatterNode.js"; import { MdnsBroadcaster } from "./mdns/MdnsBroadcaster.js"; import { MdnsScanner } from "./mdns/MdnsScanner.js"; import { UdpInterface } from "./net/UdpInterface.js"; import { CommissioningOptions } from "./protocol/ControllerCommissioner.js"; +import { ControllerDiscovery } from "./protocol/ControllerDiscovery.js"; import { InteractionClient } from "./protocol/interaction/InteractionClient.js"; import { TypeFromPartialBitSchema } from "./schema/BitmapSchema.js"; import { DiscoveryCapabilitiesBitmap } from "./schema/PairingCodeSchema.js"; @@ -74,13 +75,23 @@ export type NodeCommissioningOptions = CommissioningControllerNodeOptions & { commissioning?: CommissioningOptions; /** Discovery related options. */ - discovery: { - /** - * Device identifiers (Short or Long Discriminator, Product/Vendor-Ids, Device-type or a pre-discovered - * instance Id, or "nothing" to discover all commissionable matter devices) to use for discovery. - */ - identifierData: CommissionableDeviceIdentifiers; - + discovery: ( + | { + /** + * Device identifiers (Short or Long Discriminator, Product/Vendor-Ids, Device-type or a pre-discovered + * instance Id, or "nothing" to discover all commissionable matter devices) to use for discovery. + * If the property commissionableDevice is provided this property is ignored. + */ + identifierData: CommissionableDeviceIdentifiers; + } + | { + /** + * Commissionable device object returned by a discovery run. + * If this property is provided then identifierData and knownAddress are ignored. + */ + commissionableDevice: CommissionableDevice; + } + ) & { /** * Discovery capabilities to use for discovery. These are included in the QR code normally and defined if BLE * is supported for initial commissioning. @@ -179,6 +190,7 @@ export class CommissioningController extends MatterNode { const nodeId = await controller.commission(nodeOptions); return this.connectNode(nodeId, { + ...nodeOptions, autoSubscribe: nodeOptions.autoSubscribe ?? this.options.autoSubscribe, subscribeMinIntervalFloorSeconds: nodeOptions.subscribeMinIntervalFloorSeconds ?? this.options.subscribeMinIntervalFloorSeconds, @@ -201,9 +213,9 @@ export class CommissioningController extends MatterNode { */ async removeNode(nodeId: NodeId, tryDecommissioning = true) { const controller = this.assertControllerIsStarted(); + const node = this.connectedNodes.get(nodeId); if (tryDecommissioning) { try { - const node = this.connectedNodes.get(nodeId); if (node == undefined) { throw new ImplementationError(`Node ${nodeId} is not connected.`); } @@ -212,10 +224,21 @@ export class CommissioningController extends MatterNode { logger.warn(`Decommissioning node ${nodeId} failed with error, remove node anyway: ${error}`); } } + if (node !== undefined) { + node.close(); + } await controller.removeNode(nodeId); this.connectedNodes.delete(nodeId); } + async disconnectNode(nodeId: NodeId) { + const node = this.connectedNodes.get(nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeId} is not connected!`); + } + await this.controllerInstance?.disconnect(nodeId); + } + /** * Connect to an already paired Node. * After connection the endpoint data of the device is analyzed and an object structure is created. @@ -229,6 +252,9 @@ export class CommissioningController extends MatterNode { const existingNode = this.connectedNodes.get(nodeId); if (existingNode !== undefined) { + if (!existingNode.isConnected) { + await existingNode.reconnect(); + } return existingNode; } @@ -317,8 +343,11 @@ export class CommissioningController extends MatterNode { return controller.getCommissionedNodes() ?? []; } - /** Close network connections of the controller. */ + /** Disconnects all connected nodes and Closes the network connections and other resources of the controller. */ async close() { + for (const node of this.connectedNodes.values()) { + node.close(); + } await this.controllerInstance?.close(); this.controllerInstance = undefined; this.connectedNodes.clear(); @@ -338,6 +367,22 @@ export class CommissioningController extends MatterNode { } } + async discoverCommissionableDevices( + identifierData: CommissionableDeviceIdentifiers, + discoveryCapabilities?: TypeFromPartialBitSchema, + discoveredCallback?: (device: CommissionableDevice) => void, + timeoutSeconds = 900, + ) { + this.assertIsAddedToMatterServer(); + const controller = this.assertControllerIsStarted(); + return await ControllerDiscovery.discoverCommissionableDevices( + controller.collectScanners(discoveryCapabilities), + timeoutSeconds, + identifierData, + discoveredCallback, + ); + } + resetStorage() { this.assertControllerIsStarted( "Storage can not be reset while the controller is operating! Please close the controller first.", diff --git a/packages/matter.js/src/MatterController.ts b/packages/matter.js/src/MatterController.ts index a6fb102469..920cbe2232 100644 --- a/packages/matter.js/src/MatterController.ts +++ b/packages/matter.js/src/MatterController.ts @@ -17,7 +17,7 @@ import { GeneralCommissioning } from "./cluster/definitions/GeneralCommissioning import { Channel } from "./common/Channel.js"; import { NoProviderError } from "./common/MatterError.js"; import { Scanner } from "./common/Scanner.js"; -import { ServerAddress, ServerAddressIp } from "./common/ServerAddress.js"; +import { ServerAddress, ServerAddressIp, serverAddressToString } from "./common/ServerAddress.js"; import { tryCatchAsync } from "./common/TryCatchHandler.js"; import { Crypto } from "./crypto/Crypto.js"; import { FabricId } from "./datatype/FabricId.js"; @@ -28,25 +28,28 @@ import { Fabric, FabricBuilder, FabricJsonObject } from "./fabric/Fabric.js"; import { Logger } from "./log/Logger.js"; import { MdnsScanner } from "./mdns/MdnsScanner.js"; import { NetInterface } from "./net/NetInterface.js"; -import { NetworkError } from "./net/Network.js"; import { ChannelManager, NoChannelError } from "./protocol/ChannelManager.js"; import { CommissioningOptions, ControllerCommissioner } from "./protocol/ControllerCommissioner.js"; -import { ControllerDiscovery } from "./protocol/ControllerDiscovery.js"; +import { ControllerDiscovery, DiscoveryError } from "./protocol/ControllerDiscovery.js"; import { ExchangeManager, ExchangeProvider, MessageChannel } from "./protocol/ExchangeManager.js"; import { RetransmissionLimitReachedError } from "./protocol/MessageExchange.js"; import { InteractionClient } from "./protocol/interaction/InteractionClient.js"; import { SECURE_CHANNEL_PROTOCOL_ID } from "./protocol/securechannel/SecureChannelMessages.js"; import { StatusReportOnlySecureChannelProtocol } from "./protocol/securechannel/SecureChannelProtocol.js"; +import { TypeFromPartialBitSchema } from "./schema/BitmapSchema.js"; +import { DiscoveryCapabilitiesBitmap } from "./schema/PairingCodeSchema.js"; import { ResumptionRecord, SessionManager } from "./session/SessionManager.js"; import { CaseClient } from "./session/case/CaseClient.js"; import { PaseClient } from "./session/pase/PaseClient.js"; import { StorageContext } from "./storage/StorageContext.js"; +import { Time, Timer } from "./time/Time.js"; import { TlvEnum } from "./tlv/TlvNumber.js"; import { TlvField, TlvObject } from "./tlv/TlvObject.js"; import { TypeFromSchema } from "./tlv/TlvSchema.js"; import { TlvString } from "./tlv/TlvString.js"; import { ByteArray } from "./util/ByteArray.js"; import { isIPv6 } from "./util/Ip.js"; +import { anyPromise, createPromise } from "./util/Promises.js"; const TlvCommissioningSuccessFailureResponse = TlvObject({ /** Contain the result of the operation. */ @@ -65,6 +68,8 @@ const DEFAULT_FABRIC_INDEX = FabricIndex(1); const DEFAULT_FABRIC_ID = FabricId(1); const DEFAULT_ADMIN_VENDOR_ID = VendorId(0xfff1); +const RECONNECTION_POLLING_INTERVAL = 10 * 60 * 1000; // 10 minutes + const logger = Logger.get("MatterController"); /** @@ -186,29 +191,11 @@ export class MatterController { this.exchangeManager.addTransportInterface(netInterface); } - /** - * Commission a device by its identifier and the Passcode. If a known address is provided this is tried first - * before discovering devices in the network. If multiple addresses or devices are found, they are tried all after - * each other. It returns the NodeId of the commissioned device. - * If it throws an PairRetransmissionLimitReachedError that means that no found device responded to the pairing - * request or the passode did not match to any discovered device/address. - */ - async commission(options: NodeCommissioningOptions): Promise { - const { - commissioning: commissioningOptions = { - regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, // Set to the most restrictive if relevant - regulatoryCountryCode: "XX", - }, - discovery: { - identifierData, - discoveryCapabilities = { onIpNetwork: true }, - knownAddress, - timeoutSeconds = 30, - }, - passcode, - } = options; - + public collectScanners( + discoveryCapabilities: TypeFromPartialBitSchema = { onIpNetwork: true }, + ) { const scannersToUse = new Array(); + scannersToUse.push(this.mdnsScanner); // Scan always on IP Network if (discoveryCapabilities.ble) { @@ -235,6 +222,53 @@ export class MatterController { if (discoveryCapabilities.softAccessPoint) { logger.info("SoftAP is not supported yet"); } + return scannersToUse; + } + + /** + * Commission a device by its identifier and the Passcode. If a known address is provided this is tried first + * before discovering devices in the network. If multiple addresses or devices are found, they are tried all after + * each other. It returns the NodeId of the commissioned device. + * If it throws an PairRetransmissionLimitReachedError that means that no found device responded to the pairing + * request or the passode did not match to any discovered device/address. + */ + async commission(options: NodeCommissioningOptions): Promise { + const { + commissioning: commissioningOptions = { + regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, // Set to the most restrictive if relevant + regulatoryCountryCode: "XX", + }, + discovery: { timeoutSeconds = 30 }, + passcode, + } = options; + const commissionableDevice = + "commissionableDevice" in options.discovery ? options.discovery.commissionableDevice : undefined; + let { + discovery: { discoveryCapabilities, knownAddress }, + } = options; + let identifierData = "identifierData" in options.discovery ? options.discovery.identifierData : {}; + + if (commissionableDevice !== undefined) { + let { addresses } = commissionableDevice; + if (discoveryCapabilities !== undefined && discoveryCapabilities.ble !== true) { + // do not use BLE if not specified + addresses = addresses.filter(address => address.type !== "ble"); + } else if (discoveryCapabilities === undefined) { + discoveryCapabilities = { onIpNetwork: true, ble: addresses.some(address => address.type === "ble") }; + } + addresses.sort(a => (a.type === "udp" ? -1 : 1)); // Sort addresses to use UDP first + knownAddress = addresses[0]; + if ("instanceId" in commissionableDevice && commissionableDevice.instanceId !== undefined) { + // it is an UDP discovery + identifierData = { instanceId: commissionableDevice.instanceId as string }; + } else { + identifierData = { longDiscriminator: commissionableDevice.D }; + } + } + + discoveryCapabilities = discoveryCapabilities ?? { onIpNetwork: true }; + + const scannersToUse = this.collectScanners(discoveryCapabilities); logger.info( `Commissioning device with identifier ${Logger.toJSON(identifierData)} and ${ @@ -281,6 +315,11 @@ export class MatterController { return await this.commissionDevice(paseSecureChannel, commissioningOptions); } + async disconnect(nodeId: NodeId) { + await this.sessionManager.removeAllSessionsForNode(nodeId, true); + await this.channelManager.removeChannel(this.fabric, nodeId); + } + async removeNode(nodeId: NodeId) { logger.info(`Removing commissioned node ${nodeId} from controller.`); await this.sessionManager.removeAllSessionsForNode(nodeId); @@ -399,7 +438,7 @@ export class MatterController { await paseSecureMessageChannel.close(); // We reconnect using Case, so close PASE connection // Look for the device broadcast over MDNS and do CASE pairing - return await this.connect(peerNodeId, 120); + return await this.connect(peerNodeId, 120); // Wait maximum 120s to find the operational device for commissioning process }, ); @@ -410,53 +449,116 @@ export class MatterController { return peerNodeId; } + private async reconnectLastKnownAddress( + peerNodeId: NodeId, + operationalAddress: ServerAddressIp, + ): Promise | undefined> { + const { ip, port } = operationalAddress; + try { + logger.debug(`Resume device connection to configured server at ${ip}:${port}`); + const channel = await this.pair(peerNodeId, operationalAddress); + this.setOperationalServerAddress(peerNodeId, operationalAddress); + return channel; + } catch (error) { + if ( + error instanceof RetransmissionLimitReachedError || + (error instanceof Error && error.message.includes("EHOSTUNREACH")) + ) { + logger.debug(`Failed to resume device connection with ${ip}:${port}, discover the device ...`, error); + return undefined; + } else { + throw error; + } + } + } + + private async connectOrDiscoverNode( + peerNodeId: NodeId, + operationalAddress?: ServerAddressIp, + timeoutSeconds?: number, + ) { + const discoveryPromises = new Array<() => Promise>>(); + + // Additionally to general discovery we also try to poll the formerly known operational address + let reconnectionPollingTimer: Timer | undefined; + + if (operationalAddress !== undefined) { + const directReconnection = await this.reconnectLastKnownAddress(peerNodeId, operationalAddress); + if (directReconnection !== undefined) { + return directReconnection; + } + + if (timeoutSeconds === undefined) { + const { promise, resolver, rejecter } = createPromise>(); + + reconnectionPollingTimer = Time.getPeriodicTimer(RECONNECTION_POLLING_INTERVAL, async () => { + try { + logger.debug(`Polling for device at ${serverAddressToString(operationalAddress)} ...`); + const result = await this.reconnectLastKnownAddress(peerNodeId, operationalAddress); + if (result !== undefined && reconnectionPollingTimer?.isRunning) { + reconnectionPollingTimer?.stop(); + resolver(result); + } + } catch (error) { + if (reconnectionPollingTimer?.isRunning) { + reconnectionPollingTimer?.stop(); + rejecter(error); + } + } + }).start(); + + discoveryPromises.push(() => promise); + } + } + + discoveryPromises.push(async () => { + const scanResult = await ControllerDiscovery.discoverOperationalDevice( + this.fabric, + peerNodeId, + this.mdnsScanner, + timeoutSeconds, + timeoutSeconds === undefined, + ); + if (reconnectionPollingTimer?.isRunning) { + reconnectionPollingTimer?.stop(); + } + + const { result, resultAddress } = await ControllerDiscovery.iterateServerAddresses( + scanResult, + PairRetransmissionLimitReachedError, + async () => this.mdnsScanner.getDiscoveredOperationalDevices(this.fabric, peerNodeId), + async address => await this.pair(peerNodeId, address), + ); + + this.setOperationalServerAddress(peerNodeId, resultAddress); + return result; + }); + + return await anyPromise(discoveryPromises); + } + /** * Resume a device connection and establish a CASE session that was previously paired with the controller. This * method will try to connect to the device using the previously used server address (if set). If that fails, the * device is discovered again using its operational instance details. * It returns the operational MessageChannel on success. */ - private async resume(peerNodeId: NodeId, timeoutSeconds = 60) { + private async resume(peerNodeId: NodeId, timeoutSeconds?: number) { const operationalAddress = this.getLastOperationalAddress(peerNodeId); - if (operationalAddress !== undefined) { - const { ip, port } = operationalAddress; - try { - logger.debug(`Resume device connection to configured server at ${ip}:${port}`); - return await this.pair(peerNodeId, operationalAddress); - } catch (error) { - if ( - error instanceof RetransmissionLimitReachedError || - (error instanceof Error && error.message.includes("EHOSTUNREACH")) - ) { - logger.debug( - `Failed to resume device connection with ${ip}:${port}, discover the device ...`, - error, - ); - // TODO do not clear address if the device is "just" offline, but still try to discover it - this.clearOperationalServerAddress(peerNodeId); - } else { - throw error; - } - } - } - const scanResult = await this.mdnsScanner.findOperationalDevice(this.fabric, peerNodeId, timeoutSeconds); - if (!scanResult.length) { - throw new NetworkError( - "The operational device cannot be found on the network. Please make sure it is online.", - ); + try { + return await this.connectOrDiscoverNode(peerNodeId, operationalAddress, timeoutSeconds); + } catch (error) { + if ( + (error instanceof DiscoveryError || error instanceof PairRetransmissionLimitReachedError) && + this.commissionedNodes.has(peerNodeId) + ) { + logger.info(`Resume failed, remove all sessions for node ${peerNodeId}`); + // We remove all sessions, this also informs the PairedNode class + await this.sessionManager.removeAllSessionsForNode(peerNodeId); + } + throw error; } - - const { result, resultAddress } = await ControllerDiscovery.iterateServerAddresses( - scanResult, - PairRetransmissionLimitReachedError, - async () => this.mdnsScanner.getDiscoveredOperationalDevices(this.fabric, peerNodeId), - async address => await this.pair(peerNodeId, address), - ); - - this.setOperationalServerAddress(peerNodeId, resultAddress); - - return result; } /** Pair with an operational device (already commissioned) and establish a CASE session. */ @@ -520,13 +622,6 @@ export class MatterController { this.storeCommisionedNodes(); } - private clearOperationalServerAddress(nodeId: NodeId) { - const nodeDetails = this.commissionedNodes.get(nodeId) ?? {}; - delete nodeDetails.operationalServerAddress; - this.commissionedNodes.set(nodeId, nodeDetails); - this.storeCommisionedNodes(); - } - private getLastOperationalAddress(nodeId: NodeId) { return this.commissionedNodes.get(nodeId)?.operationalServerAddress; } @@ -553,7 +648,7 @@ export class MatterController { return new InteractionClient( new ExchangeProvider(this.exchangeManager, channel, async () => { await this.channelManager.removeChannel(this.fabric, peerNodeId); - await this.resume(peerNodeId); + await this.resume(peerNodeId, 60); // Channel reconnection only waits limited time return this.channelManager.getChannel(this.fabric, peerNodeId); }), peerNodeId, diff --git a/packages/matter.js/src/cluster/server/AdministratorCommissioningServer.ts b/packages/matter.js/src/cluster/server/AdministratorCommissioningServer.ts index 2e5c43e6ea..ed4abc384b 100644 --- a/packages/matter.js/src/cluster/server/AdministratorCommissioningServer.ts +++ b/packages/matter.js/src/cluster/server/AdministratorCommissioningServer.ts @@ -51,8 +51,9 @@ class AdministratorCommissioningManager { throw new InternalError("Commissioning window already initialized."); } logger.debug(`Commissioning window timer started for ${commissioningTimeout} seconds for ${session.name}.`); - this.commissioningWindowTimeout = Time.getTimer(commissioningTimeout * 1000, () => - this.closeCommissioningWindow(session), + this.commissioningWindowTimeout = Time.getTimer( + commissioningTimeout * 1000, + async () => await this.closeCommissioningWindow(session), ).start(); this.adminFabricIndexAttribute.setLocal(session.getAssociatedFabric().fabricIndex); diff --git a/packages/matter.js/src/common/Scanner.ts b/packages/matter.js/src/common/Scanner.ts index 7173e47c37..f1611a6020 100644 --- a/packages/matter.js/src/common/Scanner.ts +++ b/packages/matter.js/src/common/Scanner.ts @@ -14,6 +14,8 @@ import { ServerAddress, ServerAddressIp } from "./ServerAddress.js"; * The properties are named identical as in the Matter specification. */ export type CommissionableDevice = { + deviceIdentifier: string; + /** The device's addresses IP/port pairs */ addresses: ServerAddress[]; @@ -90,7 +92,12 @@ export interface Scanner { * Send DNS-SD queries to discover the current addresses of an operational paired device by its operational ID * and return them. */ - findOperationalDevice(fabric: Fabric, nodeId: NodeId, timeoutSeconds?: number): Promise; + findOperationalDevice( + fabric: Fabric, + nodeId: NodeId, + timeoutSeconds?: number, + ignoreExistingRecords?: boolean, + ): Promise; /** * Return already discovered addresses of an operational paired device and return them. Does not send out new @@ -99,17 +106,35 @@ export interface Scanner { getDiscoveredOperationalDevices(fabric: Fabric, nodeId: NodeId): ServerAddressIp[]; /** - * Send DNS-SD queries to discover commissionable devices by an provided identifier (e.g. discriminator, - * vendorId, etc.) and return them. + * Send DNS-SD queries to discover commissionable devices by a provided identifier (e.g. discriminator, + * vendorId, etc.) and returns as soon as minimum one was found or the timeout is over. */ findCommissionableDevices( identifier: CommissionableDeviceIdentifiers, timeoutSeconds?: number, + ignoreExistingRecords?: boolean, + ): Promise; + + /** + * Send DNS-SD queries to discover commissionable devices by a provided identifier (e.g. discriminator, + * vendorId, etc.) and returns after the timeout is over. For each new discovered device the provided callback is + * called when it is discovered. + */ + findCommissionableDevicesContinuously( + identifier: CommissionableDeviceIdentifiers, + callback: (device: CommissionableDevice) => void, + timeoutSeconds?: number, ): Promise; /** Return already discovered commissionable devices and return them. Does not send out new DNS-SD queries. */ getDiscoveredCommissionableDevices(identifier: CommissionableDeviceIdentifiers): CommissionableDevice[]; + /** + * Cancel a running discovery of commissionable devices. The waiter promises are resolved as if the timeout would + * be over. + */ + cancelCommissionableDeviceDiscovery(identifier: CommissionableDeviceIdentifiers): void; + /** Close the scanner server and free resources. */ close(): void; } diff --git a/packages/matter.js/src/device/PairedNode.ts b/packages/matter.js/src/device/PairedNode.ts index 28687d0583..9045bad1fe 100644 --- a/packages/matter.js/src/device/PairedNode.ts +++ b/packages/matter.js/src/device/PairedNode.ts @@ -48,12 +48,42 @@ import { QrPairingCodeCodec, } from "../schema/PairingCodeSchema.js"; import { PaseClient } from "../session/pase/PaseClient.js"; +import { Time } from "../time/Time.js"; import { DeviceTypeDefinition, DeviceTypes, UnknownDeviceType, getDeviceTypeDefinitionByCode } from "./DeviceTypes.js"; import { Endpoint } from "./Endpoint.js"; -import { logEndpoint } from "./EndpointStructureLogger.js"; +import { EndpointLoggingOptions, logEndpoint } from "./EndpointStructureLogger.js"; const logger = Logger.get("PairedNode"); +/** Delay after receiving a changed partList from a device to update the device structure */ +const STRUCTURE_UPDATE_TIMEOUT_MS = 5_000; // 5 seconds, TODO: Verify if this value makes sense in practice + +export enum NodeStateInformation { + /** Node is connected and all data is up-to-date. */ + Connected, + + /** + * Node is disconnected. Data are stale and interactions will most likely return an error. If controller instance + * is still active then the device will be reconnected once it is available again. + */ + Disconnected, + + /** Node is reconnecting. Data are stale. It is yet unknown if the reconnection is successful. */ + Reconnecting, + + /** + * The node could not be connected and the controller is now waiting for a MDNS announcement and tries every 10 + * minutes to reconnect. + */ + WaitingForDeviceDiscovery, + + /** + * Node structure has changed (Endpoints got added or also removed). Data are up-to-date. + * This State information will only be fired when the subscribeAllAttributesAndEvents option is set to true. + */ + StructureChanged, +} + export type CommissioningControllerNodeOptions = { /** * Unless set to false all events and attributes are subscribed and value changes are reflected in the ClusterClient @@ -76,13 +106,19 @@ export type CommissioningControllerNodeOptions = { * Optional additional callback method which is called for each Attribute change reported by the device. Use this * if subscribing to all relevant attributes is too much effort. */ - readonly attributeChangedCallback?: (data: DecodedAttributeReportValue) => void; + readonly attributeChangedCallback?: (nodeId: NodeId, data: DecodedAttributeReportValue) => void; /** * Optional additional callback method which is called for each Event reported by the device. Use this if * subscribing to all relevant events is too much effort. */ - readonly eventTriggeredCallback?: (data: DecodedEventReportValue) => void; + readonly eventTriggeredCallback?: (nodeId: NodeId, data: DecodedEventReportValue) => void; + + /** + * Optional callback method which is called when the state of the node changes. This can be used to detect when + * the node goes offline or comes back online. + */ + readonly stateInformationCallback?: (nodeId: NodeId, state: NodeStateInformation) => void; }; /** @@ -92,6 +128,15 @@ export type CommissioningControllerNodeOptions = { export class PairedNode { private readonly endpoints = new Map(); private interactionClient?: InteractionClient; + private readonly reconnectDelayTimer = Time.getTimer( + STRUCTURE_UPDATE_TIMEOUT_MS, + async () => await this.reconnect(), + ); + private readonly updateEndpointStructureTimer = Time.getTimer( + 5_000, + async () => await this.updateEndpointStructure(), + ); + private connectionState: NodeStateInformation = NodeStateInformation.Disconnected; static async create( nodeId: NodeId, @@ -118,20 +163,59 @@ export class PairedNode { private readonly reconnectInteractionClient: () => Promise, assignDisconnectedHandler: (handler: () => Promise) => void, ) { - assignDisconnectedHandler(async () => await this.reconnect()); + assignDisconnectedHandler(async () => { + logger.info( + `Node ${this.nodeId}: Session disconnected${ + this.connectionState !== NodeStateInformation.Disconnected ? ", trying to reconnect ..." : "" + }`, + ); + if (this.connectionState === NodeStateInformation.Connected) { + await this.reconnect(); + } + }); + } + + get isConnected() { + return this.connectionState === NodeStateInformation.Connected; } - /** Reconnect to the device after the active Session was closed. */ - private async reconnect() { + private setConnectionState(state: NodeStateInformation) { + if ( + this.connectionState === state || + (this.connectionState === NodeStateInformation.Disconnected && + state === NodeStateInformation.Reconnecting) || + (this.connectionState === NodeStateInformation.WaitingForDeviceDiscovery && + state === NodeStateInformation.Reconnecting) + ) + return; + this.connectionState = state; + this.options.stateInformationCallback?.(this.nodeId, state); + } + + /** + * Force a reconnection to the device. This method is mainly used internally to reconnect after the active session + * was closed or the device wen offline and was detected as being online again. + */ + async reconnect() { if (this.interactionClient !== undefined) { this.interactionClient.close(); this.interactionClient = undefined; } + this.setConnectionState(NodeStateInformation.Reconnecting); try { await this.initialize(); } catch (error) { - logger.warn(`Node ${this.nodeId}: Error reconnecting to device`, error); - // TODO resume logic right now retries and discovers for 60s .. prolong this but without command repeating + if (error instanceof MatterError) { + // When we already know that the node is disconnected ignore all MatterErrors and rethrow all others + if (this.connectionState === NodeStateInformation.Disconnected) { + return; + } + logger.warn(`Node ${this.nodeId}: Error waiting for device rediscovery`, error); + this.setConnectionState(NodeStateInformation.WaitingForDeviceDiscovery); + await this.reconnect(); + } else { + throw error; + } } } @@ -151,18 +235,25 @@ export class PairedNode { if (autoSubscribe !== false) { const initialSubscriptionData = await this.subscribeAllAttributesAndEvents({ ignoreInitialTriggers: true, - attributeChangedCallback, - eventTriggeredCallback, + attributeChangedCallback: data => attributeChangedCallback?.(this.nodeId, data), + eventTriggeredCallback: data => eventTriggeredCallback?.(this.nodeId, data), }); // Ignore Triggers from Subscribing during initialization if (initialSubscriptionData.attributeReports === undefined) { throw new InternalError("No attribute reports received when subscribing to all values!"); } await this.initializeEndpointStructure(initialSubscriptionData.attributeReports ?? []); + + const rootDescriptorCluster = this.getRootClusterClient(DescriptorCluster); + rootDescriptorCluster?.addPartsListAttributeListener(() => { + logger.info(`Node ${this.nodeId}: PartsList changed, reinitializing endpoint structure ...`); + this.updateEndpointStructureTimer.stop().start(); // Restart timer + }); } else { const allClusterAttributes = await interactionClient.getAllAttributes(); await this.initializeEndpointStructure(allClusterAttributes); } + this.setConnectionState(NodeStateInformation.Connected); } /** @@ -174,13 +265,13 @@ export class PairedNode { } /** Method to log the structure of this node with all endpoint and clusters. */ - logStructure() { + logStructure(options?: EndpointLoggingOptions) { const rootEndpoint = this.endpoints.get(EndpointNumber(0)); if (rootEndpoint === undefined) { logger.info(`Node ${this.nodeId} has not yet been initialized!`); return; } - logEndpoint(rootEndpoint); + logEndpoint(rootEndpoint, options); } /** @@ -289,15 +380,47 @@ export class PairedNode { /** Handles a node shutDown event (if supported by the node and received). */ private async handleNodeShutdown() { logger.info(`Node ${this.nodeId}: Node shutdown detected, trying to reconnect ...`); - await this.reconnect(); + if (!this.reconnectDelayTimer.isRunning) { + this.reconnectDelayTimer.start(); + } + this.setConnectionState(NodeStateInformation.Reconnecting); + } + + async updateEndpointStructure() { + const interactionClient = await this.ensureConnection(); + const allClusterAttributes = await interactionClient.getAllAttributes(); + await this.initializeEndpointStructure(allClusterAttributes, true); + this.options.stateInformationCallback?.(this.nodeId, NodeStateInformation.StructureChanged); } /** Reads all data from the device and create a device object structure out of it. */ - private async initializeEndpointStructure(allClusterAttributes: DecodedAttributeReportValue[]) { + private async initializeEndpointStructure( + allClusterAttributes: DecodedAttributeReportValue[], + updateStructure = false, + ) { const interactionClient = await this.ensureConnection(); const allData = structureReadAttributeDataToClusterObject(allClusterAttributes); - this.endpoints.clear(); + if (updateStructure) { + // Find out what we need to remove or retain + const endpointsToRemove = new Set(this.endpoints.keys()); + for (const [endpointId] of Object.entries(allData)) { + const endpointIdNumber = EndpointNumber(parseInt(endpointId)); + if (this.endpoints.has(endpointIdNumber)) { + logger.debug("Retaining device", endpointId); + endpointsToRemove.delete(endpointIdNumber); + } + } + // And remove all endpoints no longer in the structure + for (const endpointId of endpointsToRemove.values()) { + logger.debug("Removing device", endpointId); + this.endpoints.get(endpointId)?.removeFromStructure(); + this.endpoints.delete(endpointId); + } + } else { + this.endpoints.clear(); + } + const partLists = new Map(); for (const [endpointId, clusters] of Object.entries(allData)) { const endpointIdNumber = EndpointNumber(parseInt(endpointId)); @@ -307,6 +430,11 @@ export class PairedNode { partLists.set(endpointIdNumber, descriptorData.partsList); + if (this.endpoints.has(endpointIdNumber)) { + // Endpoint exists already, so mo need to create device instance again + continue; + } + logger.debug("Creating device", endpointId, Logger.toJSON(clusters)); this.endpoints.set(endpointIdNumber, this.createDevice(endpointIdNumber, clusters, interactionClient)); } @@ -341,17 +469,21 @@ export class PairedNode { const idsToCleanup: { [key: EndpointNumber]: boolean } = {}; singleUsageEndpoints.forEach(([childId, usages]) => { - const childEndpoint = this.endpoints.get(EndpointNumber(parseInt(childId))); + const childEndpointId = EndpointNumber(parseInt(childId)); + const childEndpoint = this.endpoints.get(childEndpointId); const parentEndpoint = this.endpoints.get(usages[0]); if (childEndpoint === undefined || parentEndpoint === undefined) { throw new InternalError(`Node ${this.nodeId}: Endpoint not found!`); // Should never happen! } - logger.debug( - `Node ${this.nodeId}: Endpoint structure: Child: ${childEndpoint.id} -> Parent: ${parentEndpoint.id}`, - ); + if (parentEndpoint.getChildEndpoint(childEndpointId) === undefined) { + logger.debug( + `Node ${this.nodeId}: Endpoint structure: Child: ${childEndpointId} -> Parent: ${parentEndpoint.id}`, + ); + + parentEndpoint.addChildEndpoint(childEndpoint); + } - parentEndpoint.addChildEndpoint(childEndpoint); delete endpointUsages[EndpointNumber(parseInt(childId))]; idsToCleanup[usages[0]] = true; }); @@ -507,6 +639,7 @@ export class PairedNode { `Removing node ${this.nodeId} failed with status ${result.statusCode} "${result.debugText}".`, ); } + this.setConnectionState(NodeStateInformation.Disconnected); await this.commissioningController.removeNode(this.nodeId, false); } @@ -604,6 +737,16 @@ export class PairedNode { }; } + async disconnect() { + this.close(); + await this.commissioningController.disconnectNode(this.nodeId); + } + + close() { + this.interactionClient?.close(); + this.setConnectionState(NodeStateInformation.Disconnected); + } + /** * Get a cluster server from the root endpoint. This is mainly used internally and not needed to be called by the user. * diff --git a/packages/matter.js/src/mdns/MdnsScanner.ts b/packages/matter.js/src/mdns/MdnsScanner.ts index e612523566..f6a11ddfef 100644 --- a/packages/matter.js/src/mdns/MdnsScanner.ts +++ b/packages/matter.js/src/mdns/MdnsScanner.ts @@ -82,8 +82,16 @@ export class MdnsScanner implements Scanner { private readonly operationalDeviceRecords = new Map>(); private readonly commissionableDeviceRecords = new Map(); - private readonly recordWaiters = new Map void; timer: Timer }>(); + private readonly recordWaiters = new Map< + string, + { + resolver: () => void; + timer?: Timer; + resolveOnUpdatedRecords: boolean; + } + >(); private readonly periodicTimer: Timer; + private closing = false; constructor( private readonly multicastServer: UdpMulticastServer, @@ -252,11 +260,18 @@ export class MdnsScanner implements Scanner { * Registers a deferred promise for a specific queryId together with a timeout and return the promise. * The promise will be resolved when the timer runs out latest. */ - private async registerWaiterPromise(queryId: string, timeoutSeconds: number) { + private async registerWaiterPromise(queryId: string, timeoutSeconds?: number, resolveOnUpdatedRecords = true) { const { promise, resolver } = createPromise(); - const timer = Time.getTimer(timeoutSeconds * 1000, () => this.finishWaiter(queryId, true)).start(); - this.recordWaiters.set(queryId, { resolver, timer }); - logger.debug(`Registered waiter for query ${queryId} with timeout ${timeoutSeconds} seconds`); + const timer = + timeoutSeconds !== undefined + ? Time.getTimer(timeoutSeconds * 1000, () => this.finishWaiter(queryId, true)).start() + : undefined; + this.recordWaiters.set(queryId, { resolver, timer, resolveOnUpdatedRecords }); + logger.debug( + `Registered waiter for query ${queryId} with ${ + timeoutSeconds !== undefined ? `timeout ${timeoutSeconds} seconds` : "no timeout" + }${resolveOnUpdatedRecords ? "" : " (not resolving on updated records)"}`, + ); return { promise }; } @@ -264,18 +279,26 @@ export class MdnsScanner implements Scanner { * Remove a waiter promise for a specific queryId and stop the connected timer. If required also resolve the * promise. */ - private finishWaiter(queryId: string, resolvePromise = false) { + private finishWaiter(queryId: string, resolvePromise: boolean, isUpdatedRecord = false) { const waiter = this.recordWaiters.get(queryId); if (waiter === undefined) return; - const { timer, resolver } = waiter; + const { timer, resolver, resolveOnUpdatedRecords } = waiter; + if (isUpdatedRecord && !resolveOnUpdatedRecords) return; logger.debug(`Finishing waiter for query ${queryId}, resolving: ${resolvePromise}`); - timer.stop(); + if (timer !== undefined) { + timer.stop(); + } if (resolvePromise) { resolver(); } this.recordWaiters.delete(queryId); } + /** Returns weather a waiter promise is registered for a specific queryId. */ + private hasWaiter(queryId: string) { + return this.recordWaiters.has(queryId); + } + private createOperationalMatterQName(operationalId: ByteArray, nodeId: NodeId) { const operationalIdString = operationalId.toHex().toUpperCase(); return getDeviceMatterQname(operationalIdString, NodeId.toHexString(nodeId)); @@ -288,11 +311,15 @@ export class MdnsScanner implements Scanner { async findOperationalDevice( { operationalId }: Fabric, nodeId: NodeId, - timeoutSeconds = 5, + timeoutSeconds?: number, + ignoreExistingRecords = false, ): Promise { + if (this.closing) { + throw new ImplementationError("Cannot discover operational device because scanner is closing."); + } const deviceMatterQname = this.createOperationalMatterQName(operationalId, nodeId); - let storedRecords = this.getOperationalDeviceRecords(deviceMatterQname); + let storedRecords = ignoreExistingRecords ? [] : this.getOperationalDeviceRecords(deviceMatterQname); if (storedRecords.length === 0) { const { promise } = await this.registerWaiterPromise(deviceMatterQname, timeoutSeconds); @@ -311,6 +338,16 @@ export class MdnsScanner implements Scanner { return storedRecords; } + cancelOperationalDeviceDiscovery(fabric: Fabric, nodeId: NodeId) { + const deviceMatterQname = this.createOperationalMatterQName(fabric.operationalId, nodeId); + this.finishWaiter(deviceMatterQname, true); + } + + cancelCommissionableDeviceDiscovery(identifier: CommissionableDeviceIdentifiers) { + const queryId = this.buildCommissionableQueryIdentifier(identifier); + this.finishWaiter(queryId, true); + } + getDiscoveredOperationalDevices({ operationalId }: Fabric, nodeId: NodeId) { return this.getOperationalDeviceRecords(this.createOperationalMatterQName(operationalId, nodeId)); } @@ -376,7 +413,7 @@ export class MdnsScanner implements Scanner { throw new ImplementationError(`Invalid commissionable device identifier : ${JSON.stringify(identifier)}`); // Should neven happen } - extractInstanceId(instanceName: string) { + private extractInstanceId(instanceName: string) { const instanceNameSeparator = instanceName.indexOf("."); if (instanceNameSeparator !== -1) { return instanceName.substring(0, instanceNameSeparator); @@ -388,6 +425,9 @@ export class MdnsScanner implements Scanner { * Check all options for a query identifier and return the most relevant one with an active query */ private findCommissionableQueryIdentifier(instanceName: string, record: CommissionableDeviceRecordWithExpire) { + if (this.closing) { + throw new ImplementationError("Cannot discover commissionable device because scanner is closing."); + } const instanceQueryId = this.buildCommissionableQueryIdentifier({ instanceId: this.extractInstanceId(instanceName), }); @@ -434,71 +474,48 @@ export class MdnsScanner implements Scanner { return undefined; } + private getCommissionableQueryRecords(identifier: CommissionableDeviceIdentifiers): DnsQuery[] { + const names = new Array(); + + names.push(MATTER_COMMISSION_SERVICE_QNAME); + + if ("instanceId" in identifier) { + names.push(getDeviceInstanceQname(identifier.instanceId)); + } else if ("longDiscriminator" in identifier) { + names.push(getLongDiscriminatorQname(identifier.longDiscriminator)); + } else if ("shortDiscriminator" in identifier) { + names.push(getShortDiscriminatorQname(identifier.shortDiscriminator)); + } else if ("vendorId" in identifier) { + names.push(getVendorQname(identifier.vendorId)); + } else if ("deviceType" in identifier) { + names.push(getDeviceTypeQname(identifier.deviceType)); + } else { + // Other queries just scan for commissionable devices + names.push(getCommissioningModeQname()); + } + + return names.map(name => ({ name, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.PTR })); + } + /** - * Discovers commissionalble devices based on a defined identifier. If an already discovered device matched the - * query it is returned directly and no query is triggered. This works because the commissionable device records - * that are announced into the network are always stored already. If no record can be found a query is registered - * and sent out and the promise gets fulfilled as soon as one device is found. More might be added later and can - * be requested ny the getCommissionableDevices method. If no device is discovered the promise is fulfilled after - * the timeout period. + * Discovers commissionable devices based on a defined identifier for maximal given timeout, but returns the + * first found entries. If already a discovered device matches in the cache the response is returned directly and + * no query is triggered. If no record exists a query is sent out and the promise gets fulfilled as soon as at least + * one device is found. If no device is discovered in the defined timeframe an empty array is returned. When the + * promise got fulfilled no more queries are send out, but more device entries might be added when discovered later. + * These can be requested by the getCommissionableDevices method. */ async findCommissionableDevices( identifier: CommissionableDeviceIdentifiers, timeoutSeconds = 5, + ignoreExistingRecords = false, ): Promise { - let storedRecords = this.getCommissionableDeviceRecords(identifier); + let storedRecords = ignoreExistingRecords ? [] : this.getCommissionableDeviceRecords(identifier); if (storedRecords.length === 0) { const queryId = this.buildCommissionableQueryIdentifier(identifier); const { promise } = await this.registerWaiterPromise(queryId, timeoutSeconds); - const queries = [ - { - name: MATTER_COMMISSION_SERVICE_QNAME, - recordClass: DnsRecordClass.IN, - recordType: DnsRecordType.PTR, - }, - ]; - - if ("instanceId" in identifier) { - queries.push({ - name: getDeviceInstanceQname(identifier.instanceId), - recordClass: DnsRecordClass.IN, - recordType: DnsRecordType.PTR, - }); - } else if ("longDiscriminator" in identifier) { - queries.push({ - name: getLongDiscriminatorQname(identifier.longDiscriminator), - recordClass: DnsRecordClass.IN, - recordType: DnsRecordType.PTR, - }); - } else if ("shortDiscriminator" in identifier) { - queries.push({ - name: getShortDiscriminatorQname(identifier.shortDiscriminator), - recordClass: DnsRecordClass.IN, - recordType: DnsRecordType.PTR, - }); - } else if ("vendorId" in identifier) { - queries.push({ - name: getVendorQname(identifier.vendorId), - recordClass: DnsRecordClass.IN, - recordType: DnsRecordType.PTR, - }); - } else if ("deviceType" in identifier) { - queries.push({ - name: getDeviceTypeQname(identifier.deviceType), - recordClass: DnsRecordClass.IN, - recordType: DnsRecordType.PTR, - }); - } else { - // Other queries just scan for commissionable devices - queries.push({ - name: getCommissioningModeQname(), - recordClass: DnsRecordClass.IN, - recordType: DnsRecordType.PTR, - }); - } - - this.setQueryRecords(queryId, queries); + this.setQueryRecords(queryId, this.getCommissionableQueryRecords(identifier)); await promise; storedRecords = this.getCommissionableDeviceRecords(identifier); @@ -508,6 +525,42 @@ export class MdnsScanner implements Scanner { return storedRecords; } + /** + * Discovers commissionable devices based on a defined identifier and returns the first found entries. If already a + * @param identifier + * @param callback + * @param timeoutSeconds + */ + async findCommissionableDevicesContinuously( + identifier: CommissionableDeviceIdentifiers, + callback: (device: CommissionableDevice) => void, + timeoutSeconds = 900, + ): Promise { + const discoveredDevices = new Set(); + + const discoveryEndTime = Time.nowMs() + timeoutSeconds * 1000; + const queryId = this.buildCommissionableQueryIdentifier(identifier); + this.setQueryRecords(queryId, this.getCommissionableQueryRecords(identifier)); + + while (true) { + this.getCommissionableDeviceRecords(identifier).forEach(device => { + const { deviceIdentifier } = device; + if (!discoveredDevices.has(deviceIdentifier)) { + discoveredDevices.add(deviceIdentifier); + callback(device); + } + }); + + const remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000); + if (remainingTime <= 0) { + break; + } + const { promise } = await this.registerWaiterPromise(queryId, remainingTime, false); + await promise; + } + return this.getCommissionableDeviceRecords(identifier); + } + getDiscoveredCommissionableDevices(identifier: CommissionableDeviceIdentifiers) { return this.getCommissionableDeviceRecords(identifier); } @@ -516,10 +569,14 @@ export class MdnsScanner implements Scanner { * Close all connects, end all timers and resolve all pending promises. */ async close() { + this.closing = true; this.periodicTimer.stop(); this.queryTimer?.stop(); await this.multicastServer.close(); - [...this.recordWaiters.keys()].forEach(queryId => this.finishWaiter(queryId, true)); + // Resolve all pending promises where logic waits for the response (aka: has a timer) + [...this.recordWaiters.keys()].forEach(queryId => + this.finishWaiter(queryId, !!this.recordWaiters.get(queryId)?.timer), + ); } /** @@ -527,6 +584,7 @@ export class MdnsScanner implements Scanner { * It will parse the message and check if it contains relevant discovery records. */ private handleDnsMessage(messageBytes: ByteArray, _remoteIp: string, netInterface: string) { + if (this.closing) return; const message = DnsCodec.decode(messageBytes); if (message === undefined) return; // The message cannot be parsed if (message.messageType !== DnsMessageType.Response && message.messageType !== DnsMessageType.TruncatedResponse) @@ -541,15 +599,20 @@ export class MdnsScanner implements Scanner { this.handleCommissionableRecords(answers, this.getActiveQueryEarlierAnswers(), netInterface); } - private handleIpRecords(answers: DnsRecord[], target: string, netInterface: string) { + private handleIpRecords( + answers: DnsRecord[], + target: string, + netInterface: string, + ): { value: string; ttl: number }[] { const ipRecords = answers.filter( ({ name, recordType }) => ((recordType === DnsRecordType.A && this.enableIpv4) || recordType === DnsRecordType.AAAA) && name === target, ); - return (ipRecords as DnsRecord[]).map(({ value }) => - value.startsWith("fe80::") ? `${value}%${netInterface}` : value, - ); + return (ipRecords as DnsRecord[]).map(({ value, ttl }) => ({ + value: value.startsWith("fe80::") ? `${value}%${netInterface}` : value, + ttl, + })); } private handleOperationalSrvRecord( @@ -557,6 +620,7 @@ export class MdnsScanner implements Scanner { formerAnswers: DnsRecord[], netInterface: string, ) { + // Does the message contain data for an operational service we already know? let operationalSrvRecord = answers.find( ({ name, recordType }) => recordType === DnsRecordType.SRV && name.endsWith(MATTER_SERVICE_QNAME), ); @@ -573,30 +637,53 @@ export class MdnsScanner implements Scanner { value: { target, port }, } = operationalSrvRecord; - const ips = this.handleIpRecords([...answers, ...formerAnswers], target, netInterface); - if (ips.length === 0 && !this.operationalDeviceRecords.has(matterName)) { - const queries = [{ name: target, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.AAAA }]; - if (this.enableIpv4) { - queries.push({ name: target, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.A }); + // we got an expiry info, so we can remove the record if we know it already and are done + if (ttl === 0) { + if (this.operationalDeviceRecords.has(matterName)) { + logger.debug( + `Removing operational device ${matterName} from cache on interface ${netInterface} because of ttl=0`, + ); + this.operationalDeviceRecords.delete(matterName); } - this.setQueryRecords(matterName, queries, answers); + return true; } + + const ips = this.handleIpRecords([...answers, ...formerAnswers], target, netInterface); + const recordExists = this.operationalDeviceRecords.has(matterName); const storedRecords = this.operationalDeviceRecords.get(matterName) ?? new Map(); if (ips.length > 0) { - for (const ip of ips) { + for (const { value: ip, ttl } of ips) { + if (ttl === 0) { + logger.debug( + `Removing IP ${ip} for operational device ${matterName} from cache on interface ${netInterface} because of ttl=0`, + ); + storedRecords.delete(ip); + continue; + } const matterServer = storedRecords.get(ip) ?? { ip, port, type: "udp", expires: 0 }; matterServer.expires = Time.nowMs() + ttl * 1000; storedRecords.set(matterServer.ip, matterServer); } + if (!this.operationalDeviceRecords.has(matterName)) { + logger.debug(`Added operational device ${matterName} to cache on interface ${netInterface}.`); + } this.operationalDeviceRecords.set(matterName, storedRecords); + } - if (storedRecords.size > 0) { - this.finishWaiter(matterName, true); - return true; + if (storedRecords.size === 0 && this.hasWaiter(matterName)) { + // We have no or no more (because expired) IPs, and we are interested in this particular service name, request them + const queries = [{ name: target, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.AAAA }]; + if (this.enableIpv4) { + queries.push({ name: target, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.A }); } + logger.debug(`Requesting IP addresses for operational device ${matterName} on interface ${netInterface}.`); + this.setQueryRecords(matterName, queries, answers); + } else if (storedRecords.size > 0) { + this.finishWaiter(matterName, true, recordExists); } + return true; } private handleCommissionableRecords( @@ -604,6 +691,7 @@ export class MdnsScanner implements Scanner { formerAnswers: DnsRecord[], netInterface: string, ) { + // Does the message contain a SRV record for an operational service we are interested in? let commissionableRecords = answers.filter(({ name }) => name.endsWith(MATTER_COMMISSION_SERVICE_QNAME)); if (!commissionableRecords.length) { commissionableRecords = formerAnswers.filter(({ name }) => name.endsWith(MATTER_COMMISSION_SERVICE_QNAME)); @@ -615,26 +703,40 @@ export class MdnsScanner implements Scanner { // First process the TXT records const txtRecords = commissionableRecords.filter(({ recordType }) => recordType === DnsRecordType.TXT); for (const record of txtRecords) { + const { name, ttl } = record; + if (ttl === 0) { + if (this.commissionableDeviceRecords.has(name)) { + logger.debug( + `Removing commissionable device ${name} from cache on interface ${netInterface} because of ttl=0`, + ); + this.commissionableDeviceRecords.delete(name); + } + continue; + } const parsedRecord = this.parseCommissionableTxtRecord(record); if (parsedRecord === undefined) continue; - const storedRecord = this.commissionableDeviceRecords.get(record.name); + parsedRecord.instanceId = this.extractInstanceId(name); + parsedRecord.deviceIdentifier = parsedRecord.instanceId; + if (parsedRecord.D !== undefined && parsedRecord.SD === undefined) { + parsedRecord.SD = (parsedRecord.D >> 8) & 0x0f; + } + if (parsedRecord.VP !== undefined) { + const VpValueArr = parsedRecord.VP.split("+"); + parsedRecord.V = VpValueArr[0] !== undefined ? parseInt(VpValueArr[0]) : undefined; + parsedRecord.P = VpValueArr[1] !== undefined ? parseInt(VpValueArr[1]) : undefined; + } + + const storedRecord = this.commissionableDeviceRecords.get(name); if (storedRecord === undefined) { - queryMissingDataForInstances.add(record.name); - parsedRecord.instanceId = this.extractInstanceId(record.name); - if (parsedRecord.D !== undefined && parsedRecord.SD === undefined) { - parsedRecord.SD = (parsedRecord.D >> 8) & 0x0f; - } - if (parsedRecord.VP !== undefined) { - const VpValueArr = parsedRecord.VP.split("+"); - parsedRecord.V = VpValueArr[0] !== undefined ? parseInt(VpValueArr[0]) : undefined; - parsedRecord.P = VpValueArr[1] !== undefined ? parseInt(VpValueArr[1]) : undefined; - } + queryMissingDataForInstances.add(name); logger.debug( - `Found commissionable device ${record.name} with discriminator ${parsedRecord.D}/${parsedRecord.SD} ...`, + `Found commissionable device ${name} with discriminator ${parsedRecord.D}/${parsedRecord.SD} ...`, ); - this.commissionableDeviceRecords.set(record.name, parsedRecord); + } else { + parsedRecord.addresses = storedRecord.addresses; } + this.commissionableDeviceRecords.set(name, parsedRecord); } // We got SRV records for the instance ID, so we know the host name now and can collect the IP addresses @@ -646,32 +748,53 @@ export class MdnsScanner implements Scanner { value: { target, port }, ttl, } = record as DnsRecord; + if (ttl === 0) { + logger.debug( + `Removing commissionable device ${record.name} from cache on interface ${netInterface} because of ttl=0`, + ); + this.commissionableDeviceRecords.delete(record.name); + continue; + } + + const recordExisting = storedRecord.addresses.size > 0; const ips = this.handleIpRecords([...answers, ...formerAnswers], target, netInterface); - if (ips.length === 0) { + if (ips.length > 0) { + for (const { value: ip, ttl } of ips) { + if (ttl === 0) { + logger.debug( + `Removing IP ${ip} for commissionable device ${record.name} from cache on interface ${netInterface} because of ttl=0`, + ); + storedRecord.addresses.delete(ip); + continue; + } + const matterServer = storedRecord.addresses.get(ip) ?? { ip, port, type: "udp", expires: 0 }; + matterServer.expires = Time.nowMs() + ttl * 1000; + + storedRecord.addresses.set(ip, matterServer); + } + } + this.commissionableDeviceRecords.set(record.name, storedRecord); + if (storedRecord.addresses.size === 0) { const queryId = this.findCommissionableQueryIdentifier("", storedRecord); if (queryId === undefined) continue; + // We have no or no more (because expired) IPs and we are interested in such a service name, request them const queries = [{ name: target, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.AAAA }]; if (this.enableIpv4) { queries.push({ name: target, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.A }); } + logger.debug( + `Requesting IP addresses for commissionable device ${record.name} on interface ${netInterface}.`, + ); this.setQueryRecords(queryId, queries, answers); - } else { - for (const ip of ips) { - const matterServer = storedRecord.addresses.get(ip) ?? { ip, port, type: "udp", expires: 0 }; - matterServer.expires = Time.nowMs() + ttl * 1000; - - storedRecord.addresses.set(ip, matterServer); - } } - this.commissionableDeviceRecords.set(record.name, storedRecord); - if (storedRecord.addresses.size == 0) return; + if (storedRecord.addresses.size === 0) continue; const queryId = this.findCommissionableQueryIdentifier(record.name, storedRecord); if (queryId === undefined) continue; queryMissingDataForInstances.delete(record.name); // No need to query anymore, we have anything we need - this.finishWaiter(queryId, true); + this.finishWaiter(queryId, true, recordExisting); } // We have to query for the SRV records for the missing commissionable devices where we only had TXT records @@ -681,6 +804,7 @@ export class MdnsScanner implements Scanner { if (storedRecord === undefined) continue; const queryId = this.findCommissionableQueryIdentifier("", storedRecord); if (queryId === undefined) continue; + logger.debug(`Requesting more records for commissionable device ${name} on interface ${netInterface}.`); this.setQueryRecords( queryId, [{ name, recordClass: DnsRecordClass.IN, recordType: DnsRecordType.ANY }], @@ -728,7 +852,9 @@ export class MdnsScanner implements Scanner { addresses.delete(key); }); } - this.commissionableDeviceRecords.delete(recordKey); + if (now >= expires || addresses.size === 0) { + this.commissionableDeviceRecords.delete(recordKey); + } }); } } diff --git a/packages/matter.js/src/protocol/ControllerDiscovery.ts b/packages/matter.js/src/protocol/ControllerDiscovery.ts index 4da10b2695..6da23e1b97 100644 --- a/packages/matter.js/src/protocol/ControllerDiscovery.ts +++ b/packages/matter.js/src/protocol/ControllerDiscovery.ts @@ -5,29 +5,36 @@ */ import { PairRetransmissionLimitReachedError } from "../MatterController.js"; -import { CommissionableDeviceIdentifiers, Scanner } from "../common/Scanner.js"; -import { ServerAddress, serverAddressToString } from "../common/ServerAddress.js"; +import { MatterError } from "../common/MatterError.js"; +import { CommissionableDevice, CommissionableDeviceIdentifiers, Scanner } from "../common/Scanner.js"; +import { ServerAddress, ServerAddressIp, serverAddressToString } from "../common/ServerAddress.js"; +import { NodeId } from "../datatype/NodeId.js"; +import { Fabric } from "../fabric/Fabric.js"; import { Logger } from "../log/Logger.js"; +import { MdnsScanner } from "../mdns/MdnsScanner.js"; import { CommissioningError } from "../protocol/ControllerCommissioner.js"; import { isDeepEqual } from "../util/DeepEqual.js"; +import { anyPromise } from "../util/Promises.js"; import { ClassExtends } from "../util/Type.js"; const logger = Logger.get("ControllerDiscovery"); +export class DiscoveryError extends MatterError {} + export class ControllerDiscovery { /** * Discovers devices by a provided identifier and a list of scanners (e.g. IP and BLE in parallel). * It returns after the timeout or if at least one device was found. * The method returns a list of addresses of the discovered devices. */ - static discoverDeviceAddressesByIdentifier( + static async discoverDeviceAddressesByIdentifier( scanners: Array, identifier: CommissionableDeviceIdentifiers, timeoutSeconds = 30, ): Promise { logger.info(`Start Discovering devices using identifier ${Logger.toJSON(identifier)} ...`); - const scanResults = scanners.map(scanner => async () => { + const scanResults = scanners.map(async scanner => { const foundDevices = await scanner.findCommissionableDevices(identifier, timeoutSeconds); logger.info(`Found ${foundDevices.length} devices using identifier ${Logger.toJSON(identifier)}`); if (foundDevices.length === 0) { @@ -49,27 +56,79 @@ export class ControllerDiscovery { return addresses; }); - // Work around unavailable Promise.any :-) - return new Promise((resolve, reject) => { - let numberRejected = 0; - let wasResolved = false; - - for (const scanResult of scanResults) { - scanResult() - .then(addresses => { - if (!wasResolved) { - wasResolved = true; - resolve(addresses); - } - }) - .catch(error => { - numberRejected++; - if (!wasResolved && numberRejected === scanners.length) { - reject(error); + return await anyPromise(scanResults); + } + + static async discoverCommissionableDevices( + scanners: Array, + timeoutSeconds: number, + identifier: CommissionableDeviceIdentifiers = {}, + discoveredCallback?: (device: CommissionableDevice) => void, + ): Promise { + const discoveredDevices = new Map(); + + await Promise.all( + scanners.map(async scanner => { + await scanner.findCommissionableDevicesContinuously( + identifier, + device => { + const { deviceIdentifier } = device; + if (!discoveredDevices.has(deviceIdentifier)) { + discoveredDevices.set(deviceIdentifier, device); + discoveredCallback?.(device); } - }); - } + }, + timeoutSeconds, + ); + }), + ); + + // The final answer only consists the devices still left, so expired ones will be excluded + const finalDiscoveredDevices = new Map(); + scanners.forEach(scanner => { + const devices = scanner.getDiscoveredCommissionableDevices(identifier); + devices.forEach(device => { + const { deviceIdentifier } = device; + if (!discoveredDevices.has(deviceIdentifier)) { + discoveredDevices.set(deviceIdentifier, device); + discoveredCallback?.(device); + } + if (!finalDiscoveredDevices.has(deviceIdentifier)) { + finalDiscoveredDevices.set(deviceIdentifier, device); + } + }); }); + + return Array.from(finalDiscoveredDevices.values()); + } + + static async discoverOperationalDevice( + fabric: Fabric, + peerNodeId: NodeId, + scanner: MdnsScanner, + timeoutSeconds?: number, + ignoreExistingRecords?: boolean, + ): Promise { + const scanResult = await scanner.findOperationalDevice( + fabric, + peerNodeId, + timeoutSeconds, + ignoreExistingRecords, + ); + if (!scanResult.length) { + throw new DiscoveryError( + "The operational device cannot be found on the network. Please make sure it is online.", + ); + } + return scanResult; + } + + static cancelOperationalDeviceDiscovery(fabric: Fabric, peerNodeId: NodeId, scanner: MdnsScanner) { + scanner.cancelOperationalDeviceDiscovery(fabric, peerNodeId); + } + + static cancelCommissionableDeviceDiscovery(scanner: Scanner, identifier: CommissionableDeviceIdentifiers = {}) { + scanner.cancelCommissionableDeviceDiscovery(identifier); } /** @@ -123,7 +182,7 @@ export class ControllerDiscovery { } } if (!triedOne) { - throw new PairRetransmissionLimitReachedError(`Failed to connect on any found server`); + throw new PairRetransmissionLimitReachedError(`Failed to connect on any discovered server`); } } } diff --git a/packages/matter.js/src/util/Cache.ts b/packages/matter.js/src/util/Cache.ts index 0f88b3b83d..c198032656 100644 --- a/packages/matter.js/src/util/Cache.ts +++ b/packages/matter.js/src/util/Cache.ts @@ -9,6 +9,7 @@ import { Time, Timer } from "../time/Time.js"; export class Cache { + private readonly knownKeys = new Set(); private readonly values = new Map(); private readonly timestamps = new Map(); private readonly periodicTimer: Timer; @@ -27,13 +28,14 @@ export class Cache { if (value === undefined) { value = this.generator(...params); this.values.set(key, value); + this.knownKeys.add(key); } this.timestamps.set(key, Time.nowMs()); return value; } keys() { - return Array.from(this.values.keys()); + return Array.from(this.knownKeys.values()); } private async deleteEntry(key: string) { @@ -55,6 +57,7 @@ export class Cache { async close() { await this.clear(); + this.knownKeys.clear(); this.periodicTimer.stop(); } diff --git a/packages/matter.js/src/util/Promises.ts b/packages/matter.js/src/util/Promises.ts index 998ad2b3f9..c6db98bdc4 100644 --- a/packages/matter.js/src/util/Promises.ts +++ b/packages/matter.js/src/util/Promises.ts @@ -34,3 +34,30 @@ export function createPromise(): { rejecter, }; } + +/** + * Use all promises or promise returning methods and return the first resolved promise or reject when all promises + * rejected + */ +export function anyPromise(promises: ((() => Promise) | Promise)[]): Promise { + return new Promise((resolve, reject) => { + let numberRejected = 0; + let wasResolved = false; + for (const entry of promises) { + const promise = typeof entry === "function" ? entry() : entry; + promise + .then(value => { + if (!wasResolved) { + wasResolved = true; + resolve(value); + } + }) + .catch(reason => { + numberRejected++; + if (!wasResolved && numberRejected === promises.length) { + reject(reason); + } + }); + } + }); +} diff --git a/packages/matter.js/test/mdns/MdnsTest.ts b/packages/matter.js/test/mdns/MdnsTest.ts index f12a9c1ea2..6a3c80a2be 100644 --- a/packages/matter.js/test/mdns/MdnsTest.ts +++ b/packages/matter.js/test/mdns/MdnsTest.ts @@ -821,7 +821,7 @@ const NODE_ID = NodeId(BigInt(1)); }); describe("integration", () => { - it("the client directly returns server record if it has been announced before", async () => { + it("the client directly returns server record if it has been announced before and records are removed on cancel", async () => { let queryReceived = false; let dataWereSent = false; const listener = scannerChannel.onData((_netInterface, _peerAddress, _peerPort, data) => { @@ -848,8 +848,18 @@ const NODE_ID = NodeId(BigInt(1)); expect(result).deep.equal(IPIntegrationResultsPort1); await listener.close(); + // Same result when we just get the records + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal(IPIntegrationResultsPort1); + // And expire the announcement await processRecordExpiry(PORT); + + // And empty result after expiry + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal([]); }); it("the client queries the server record if it has not been announced before", async () => { @@ -887,8 +897,18 @@ const NODE_ID = NodeId(BigInt(1)); expect(result).deep.equal(IPIntegrationResultsPort1); await listener.close(); + // Same result when we just get the records + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal(IPIntegrationResultsPort1); + // And expire the announcement await processRecordExpiry(PORT); + + // And empty result after expiry + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal([]); }); it("the client queries the server record and get correct response also with multiple announced instances", async () => { @@ -965,9 +985,22 @@ const NODE_ID = NodeId(BigInt(1)); await listener.close(); + // Same result when we just get the records + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal(IPIntegrationResultsPort2); + + // No commissionable devices because never queried + expect(scanner.getDiscoveredCommissionableDevices({ longDiscriminator: 1234 })).deep.equal([]); + // And expire the announcement await processRecordExpiry(PORT); await processRecordExpiry(PORT2); + + // And empty result after expiry + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal([]); }); it("the client queries the server record and get correct response when announced before", async () => { @@ -992,13 +1025,13 @@ const NODE_ID = NodeId(BigInt(1)); await broadcaster.announce(PORT); await broadcaster.announce(PORT2); - await MockTime.yield3(); await MockTime.yield3(); const result = await scanner.findOperationalDevice( { operationalId: OPERATIONAL_ID } as Fabric, NODE_ID, + 10, ); expect(dataWereSent).equal(true); @@ -1007,9 +1040,44 @@ const NODE_ID = NodeId(BigInt(1)); await listener.close(); + // Same result when we just get the records + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal(IPIntegrationResultsPort2); + + // Also commissionable devices known now + expect(scanner.getDiscoveredCommissionableDevices({ longDiscriminator: 1234 })).deep.equal([ + { + CM: 1, + D: 1234, + DN: "Test Device", + DT: 1, + P: 32768, + PH: 33, + PI: "", + SAI: 300, + SD: 4, + SII: 5000, + T: 0, + V: 1, + VP: "1+32768", + addresses: IPIntegrationResultsPort1, + deviceIdentifier: "0000000000000000", + expires: undefined, + instanceId: "0000000000000000", + }, + ]); + // And expire the announcement await processRecordExpiry(PORT); await processRecordExpiry(PORT2); + + // And removed after expiry + expect( + scanner.getDiscoveredOperationalDevices({ operationalId: OPERATIONAL_ID } as Fabric, NODE_ID), + ).deep.equal([]); + + expect(scanner.getDiscoveredCommissionableDevices({ longDiscriminator: 1234 })).deep.equal([]); }); }); }); diff --git a/tools/package.json b/tools/package.json index 96928a968f..6b3eb7e41d 100644 --- a/tools/package.json +++ b/tools/package.json @@ -1,6 +1,6 @@ { "name": "@project-chip/matter.js-tools", - "version": "0.6.1-alpha.0-20231102-e09231e", + "version": "0.6.1-alpha.0-20231106-8322fa6", "description": "Matter.js tooling", "private": true, "type": "module", @@ -65,6 +65,6 @@ "@types/glob": "^8.1.0", "@types/mocha": "^10.0.3", "@types/npmcli__map-workspaces": "^3.0.3", - "@types/yargs": "^17.0.28" + "@types/yargs": "^17.0.29" } }