diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a976a6e06..25d70f712b 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,6 +34,9 @@ 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. + * 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/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/src/MatterNode.ts b/packages/matter-node-shell.js/src/MatterNode.ts index a831216b92..81d9fb27de 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 nodeNum: number) {} @@ -52,7 +55,7 @@ export class MatterNode { } async close() { - await this.matterDevice?.close(); + await this.matterController?.close(); this.closeStorage(); } @@ -67,7 +70,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}`); @@ -85,11 +88,11 @@ export class MatterNode { * are called. */ - this.matterDevice = new MatterServer(this.storageManager); + this.matterController = new MatterServer(this.storageManager); this.commissioningController = new CommissioningController({ autoConnect: false, }); - this.matterDevice.addCommissioningController(this.commissioningController); + this.matterController.addCommissioningController(this.commissioningController); /** * Start the Matter Server @@ -98,7 +101,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 8f67bb95a9..42bcf3a698 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"; @@ -85,7 +75,7 @@ async function main() { const { nodeNum, ble, nodeType, resetStorage } = argv; - const theNode = new MatterNode(nodeNum); + theNode = new MatterNode(nodeNum); await theNode.initialize(resetStorage); const theShell = new Shell(theNode, PROMPT); 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/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/test/IntegrationTest.ts b/packages/matter-node.js/test/IntegrationTest.ts index a5358607f1..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,6 +1471,8 @@ describe("Integration Test", () => { regulatoryCountryCode: "DE", }, passcode, + stateInformationCallback: (nodeId: NodeId, nodeState: NodeStateInformation) => + nodeStateChangesController2Node1.push({ nodeId, nodeState, time: MockTime.nowMs() }), }), ); @@ -1451,6 +1484,9 @@ describe("Integration Test", () => { 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/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([]); }); }); });