diff --git a/CHANGELOG.md b/CHANGELOG.md index f71650cd2a..7a976a6e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The main work (all changes without a GitHub username in brackets in the below li * Fix: Handles event data correctly on subscription initially and also on updates to trigger the listeners * Enhance (vilic): Added MDNS Memberships to sockets for better operation on Windows and other platforms * Enhance: Refactor session management and make sure also controller handle session close requests from devices + * Enhance: Refactor close handing for exchanges and channels to make sure they are closed correctly * Feature: Added detection of missing Subscription updates from a device and allow to react to such a timeout with callback * Feature: Added generation method for random passcodes to PaseClient * Feature: Generalized Discovery logic and allow discoveries via different methods (BLE+IP) in parallel diff --git a/packages/matter-node.js-examples/src/examples/DeviceNode.ts b/packages/matter-node.js-examples/src/examples/DeviceNode.ts index 9c44b02e1b..7af74eb9b0 100644 --- a/packages/matter-node.js-examples/src/examples/DeviceNode.ts +++ b/packages/matter-node.js-examples/src/examples/DeviceNode.ts @@ -186,6 +186,18 @@ class Device { serialNumber: `node-matter-${uniqueId}`, }, delayedAnnouncement: hasParameter("ble"), // Delay announcement when BLE is used to show how limited advertisement works + activeSessionsChangedCallback: fabricIndex => { + console.log( + `activeSessionsChangedCallback: Active sessions changed on Fabric ${fabricIndex}`, + commissioningServer.getActiveSessionInformation(fabricIndex), + ); + }, + commissioningChangedCallback: fabricIndex => { + console.log( + `commissioningChangedCallback: Commissioning changed on Fabric ${fabricIndex}`, + commissioningServer.getCommissionedFabricInformation(fabricIndex)[0], + ); + }, }); // optionally add a listener for the testEventTrigger command from the GeneralDiagnostics cluster diff --git a/packages/matter-node.js/test/IntegrationTest.ts b/packages/matter-node.js/test/IntegrationTest.ts index c60a33a3c7..cd46a413d7 100644 --- a/packages/matter-node.js/test/IntegrationTest.ts +++ b/packages/matter-node.js/test/IntegrationTest.ts @@ -83,6 +83,10 @@ describe("Integration Test", () => { let serverMdnsScanner: MdnsScanner; let clientMdnsScanner: MdnsScanner; let mdnsBroadcaster: MdnsBroadcaster; + const commissioningChangedCallsServer = new Array<{ fabricIndex: FabricIndex; time: number }>(); + const commissioningChangedCallsServer2 = new Array<{ fabricIndex: FabricIndex; time: number }>(); + const sessionChangedCallsServer = new Array<{ fabricIndex: FabricIndex; time: number }>(); + const sessionChangedCallsServer2 = new Array<{ fabricIndex: FabricIndex; time: number }>(); before(async () => { MockTime.reset(TIME_START); @@ -133,6 +137,10 @@ describe("Integration Test", () => { reachable: true, }, delayedAnnouncement: true, // delay because we need to override Mdns classes + commissioningChangedCallback: (fabricIndex: FabricIndex) => + commissioningChangedCallsServer.push({ fabricIndex, time: MockTime.nowMs() }), + activeSessionsChangedCallback: (fabricIndex: FabricIndex) => + sessionChangedCallsServer.push({ fabricIndex, time: MockTime.nowMs() }), }); assert.equal(commissioningServer.getPort(), undefined); @@ -245,6 +253,9 @@ describe("Integration Test", () => { const mockTimeInstance = Time.get(); Time.get = singleton(() => new TimeNode()); + assert.equal(commissioningChangedCallsServer.length, 0); + assert.equal(sessionChangedCallsServer.length, 0); + await commissioningController.start(); const node = await commissioningController.commissionNode({ discovery: { @@ -265,12 +276,30 @@ describe("Integration Test", () => { }; assert.deepEqual(commissioningController.getCommissionedNodes(), [node.nodeId]); + assert.equal(commissioningChangedCallsServer.length, 1); + assert.equal(commissioningChangedCallsServer[0].fabricIndex, FabricIndex(1)); + assert.equal(sessionChangedCallsServer.length, 1); + assert.equal(sessionChangedCallsServer[0].fabricIndex, FabricIndex(1)); + const sessionInfo = commissioningServer.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.equal(sessionInfo[0].fabric.fabricIndex, FabricIndex(1)); + assert.equal(sessionInfo[0].nodeId, node.nodeId); }); - it("We can connect to the new comissioned device", async () => { + it("We can connect to the new commissioned device", async () => { const nodeId = commissioningController.getCommissionedNodes()[0]; await commissioningController.connectNode(nodeId); + + assert.equal(commissioningChangedCallsServer.length, 1); + assert.equal(sessionChangedCallsServer.length, 1); + assert.equal(sessionChangedCallsServer[0].fabricIndex, FabricIndex(1)); + const sessionInfo = commissioningServer.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.equal(sessionInfo[0].fabric.fabricIndex, FabricIndex(1)); + assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 0); }); it("Subscribe to all Attributes and bind updates to them", async () => { @@ -281,6 +310,15 @@ describe("Integration Test", () => { assert.equal(Array.isArray(data.attributeReports), true); assert.equal(Array.isArray(data.eventReports), true); + + assert.equal(commissioningChangedCallsServer.length, 1); + assert.equal(sessionChangedCallsServer.length, 2); + assert.equal(sessionChangedCallsServer[1].fabricIndex, FabricIndex(1)); + const sessionInfo = commissioningServer.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.equal(sessionInfo[0].fabric.fabricIndex, FabricIndex(1)); + assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 1); }); it("Verify that commissioning changed the Regulatory Config/Location values", async () => { @@ -827,6 +865,14 @@ describe("Integration Test", () => { const lastReport = await lastPromise; assert.deepEqual(lastReport, { value: false, time: startTime + (10 + 2) * 1000 + 200 }); + + assert.equal(commissioningChangedCallsServer.length, 1); + assert.equal(sessionChangedCallsServer.length, 3); + assert.equal(sessionChangedCallsServer[2].fabricIndex, FabricIndex(1)); + const sessionInfo = commissioningServer.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 2); }); it("another additional subscription of one attribute with known data version only sends updates when the value changes", async () => { @@ -1024,6 +1070,16 @@ describe("Integration Test", () => { time: startTime + 200 + 101, }); }); + + 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)); + const sessionInfo = commissioningServer.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.ok(sessionInfo[0].numberOfActiveSubscriptions >= 5); + }); }); describe("Access Control server fabric scoped attribute storage", () => { @@ -1149,6 +1205,10 @@ describe("Integration Test", () => { reachable: true, }, delayedAnnouncement: true, // delay because we need to override Mdns classes + commissioningChangedCallback: (fabricIndex: FabricIndex) => + commissioningChangedCallsServer2.push({ fabricIndex, time: MockTime.nowMs() }), + activeSessionsChangedCallback: (fabricIndex: FabricIndex) => + sessionChangedCallsServer2.push({ fabricIndex, time: MockTime.nowMs() }), }); onOffLightDeviceServer = new OnOffLightDevice(); @@ -1161,6 +1221,9 @@ describe("Integration Test", () => { commissioningServer2.setMdnsBroadcaster(mdnsBroadcaster); await assert.doesNotReject(async () => commissioningServer2.advertise()); + + assert.equal(commissioningChangedCallsServer2.length, 0); + assert.equal(sessionChangedCallsServer2.length, 0); }); it("the client commissions the second device", async () => { @@ -1186,12 +1249,28 @@ describe("Integration Test", () => { Time.get = () => mockTimeInstance; assert.deepEqual(commissioningController.getCommissionedNodes(), [...existingNodes, node.nodeId]); + + assert.equal(commissioningChangedCallsServer2.length, 1); + assert.equal(sessionChangedCallsServer2.length, 1); + assert.equal(sessionChangedCallsServer2[0].fabricIndex, FabricIndex(1)); + const sessionInfo = commissioningServer2.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 0); }); it("We can connect to the new commissioned device", async () => { const nodeId = commissioningController.getCommissionedNodes()[1]; await commissioningController.connectNode(nodeId); + + assert.equal(commissioningChangedCallsServer2.length, 1); + assert.equal(sessionChangedCallsServer2.length, 1); + assert.equal(sessionChangedCallsServer2[0].fabricIndex, FabricIndex(1)); + const sessionInfo = commissioningServer2.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 0); }); it("Subscribe to all Attributes and bind updates to them for second device", async () => { @@ -1202,6 +1281,14 @@ describe("Integration Test", () => { assert.equal(Array.isArray(data.attributeReports), true); assert.equal(Array.isArray(data.eventReports), true); + + assert.equal(commissioningChangedCallsServer2.length, 1); + assert.equal(sessionChangedCallsServer2.length, 2); + assert.equal(sessionChangedCallsServer2[0].fabricIndex, FabricIndex(1)); + const sessionInfo = commissioningServer2.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 1); + assert.ok(sessionInfo[0].fabric); + assert.equal(sessionInfo[0].numberOfActiveSubscriptions, 1); }); it("controller storage is updated for second device", async () => { @@ -1319,6 +1406,9 @@ describe("Integration Test", () => { passcode, }), ); // We can not check the real exception because text is dynamic + + assert.equal(commissioningChangedCallsServer2.length, 1); + assert.equal(commissioningChangedCallsServer.length, 1); }); it("connect this device to a new controller", async () => { @@ -1352,6 +1442,15 @@ describe("Integration Test", () => { passcode, }), ); + + assert.equal(commissioningChangedCallsServer.length, 2); + assert.ok(sessionChangedCallsServer.length >= 7); + assert.equal(sessionChangedCallsServer[7].fabricIndex, FabricIndex(2)); + const sessionInfo = commissioningServer.getActiveSessionInformation(); + assert.equal(sessionInfo.length, 2); + assert.ok(sessionInfo[1].fabric); + assert.equal(sessionInfo[1].numberOfActiveSubscriptions, 0); + assert.equal(commissioningChangedCallsServer2.length, 1); }).timeout(10_000); it("verify that the server storage got updated", async () => { @@ -1454,13 +1553,17 @@ describe("Integration Test", () => { const secondNodeId = commissioningController.getCommissionedNodes()[1]; const node = commissioningController.getConnectedNode(nodeId); assert.ok(node); - await assert.doesNotReject(async () => node.decommission()); + await assert.doesNotReject(async () => await node.decommission()); assert.equal(commissioningController.getCommissionedNodes().length, 1); assert.equal(commissioningController.getCommissionedNodes()[0], secondNodeId); + + assert.equal(commissioningChangedCallsServer.length, 3); + assert.equal(commissioningChangedCallsServer[2].fabricIndex, FabricIndex(1)); + assert.equal(commissioningChangedCallsServer2.length, 1); }); - it("read and remove second node by removing fabric from device unplanned", async () => { + it("read and remove second node by removing fabric from device unplanned and doing factory reset", async () => { // We remove the node ourselves (should not be done that way), but for testing we do const nodeId = commissioningController.getCommissionedNodes()[0]; const node = commissioningController.getConnectedNode(nodeId); @@ -1480,12 +1583,29 @@ describe("Integration Test", () => { assert.equal(result.statusCode, OperationalCredentials.NodeOperationalCertStatus.Ok); assert.deepEqual(result.fabricIndex, fabricIndex); + let i; + for (i = 0; i < 20; i++) { + if (commissioningChangedCallsServer2.length === 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + break; + } + } + assert.ok(i !== 0); + assert.equal(commissioningChangedCallsServer2.length, 2); + + assert.equal(commissioningController.getCommissionedNodes().length, 1); + // Try to remove node now will throw an error await assert.rejects(async () => await node.decommission()); await assert.doesNotReject(async () => await commissioningController.removeNode(nodeId, false)); Time.get = () => mockTimeInstance; + + assert.equal(commissioningController.getCommissionedNodes().length, 0); + assert.equal(commissioningChangedCallsServer2.length, 2); + assert.equal(commissioningChangedCallsServer2[1].fabricIndex, FabricIndex(1)); }).timeout(30_000); it("controller storage is updated for removed nodes", async () => { diff --git a/packages/matter-node.js/test/cluster/ClusterServerTestingUtil.ts b/packages/matter-node.js/test/cluster/ClusterServerTestingUtil.ts index c5bad02735..f4ee65c571 100644 --- a/packages/matter-node.js/test/cluster/ClusterServerTestingUtil.ts +++ b/packages/matter-node.js/test/cluster/ClusterServerTestingUtil.ts @@ -56,20 +56,20 @@ export async function createTestSessionWithFabric() { ZERO, "", ); - return await SecureSession.create( - {} as any, - 1, - testFabric, - NodeId(BigInt(1)), - 1, - ZERO, - ZERO, - false, - false, - async () => { + return await SecureSession.create({ + context: {} as any, + id: 1, + fabric: testFabric, + peerNodeId: NodeId(BigInt(1)), + peerSessionId: 1, + sharedSecret: ZERO, + salt: ZERO, + isInitiator: false, + isResumption: false, + closeCallback: async () => { /* */ }, - 1, - 2, - ); + idleRetransmissionTimeoutMs: 1, + activeRetransmissionTimeoutMs: 2, + }); } diff --git a/packages/matter-node.js/test/fabric/FabricTest.ts b/packages/matter-node.js/test/fabric/FabricTest.ts index 864845ec32..4dda6faf7f 100644 --- a/packages/matter-node.js/test/fabric/FabricTest.ts +++ b/packages/matter-node.js/test/fabric/FabricTest.ts @@ -121,35 +121,33 @@ describe("Fabric", () => { let session1Destroyed = false; let session2Destroyed = false; - const secureSession1 = new SecureSession( - {} as any, - 1, - undefined, - UNDEFINED_NODE_ID, - 0x8d4b, - Buffer.alloc(0), - DECRYPT_KEY, - ENCRYPT_KEY, - Buffer.alloc(0), - async () => { + const secureSession1 = new SecureSession({ + context: {} as any, + id: 1, + fabric: undefined, + peerNodeId: UNDEFINED_NODE_ID, + peerSessionId: 0x8d4b, + decryptKey: DECRYPT_KEY, + encryptKey: ENCRYPT_KEY, + attestationKey: Buffer.alloc(0), + closeCallback: async () => { session1Destroyed = true; }, - ); + }); fabric.addSession(secureSession1); - const secureSession2 = new SecureSession( - {} as any, - 2, - undefined, - UNDEFINED_NODE_ID, - 0x8d4b, - Buffer.alloc(0), - DECRYPT_KEY, - ENCRYPT_KEY, - Buffer.alloc(0), - async () => { + const secureSession2 = new SecureSession({ + context: {} as any, + id: 2, + fabric: undefined, + peerNodeId: UNDEFINED_NODE_ID, + peerSessionId: 0x8d4b, + decryptKey: DECRYPT_KEY, + encryptKey: ENCRYPT_KEY, + attestationKey: Buffer.alloc(0), + closeCallback: async () => { session2Destroyed = true; }, - ); + }); fabric.addSession(secureSession2); let removeCallbackCalled = false; @@ -176,35 +174,33 @@ describe("Fabric", () => { let session1Destroyed = false; let session2Destroyed = false; - const secureSession1 = new SecureSession( - {} as any, - 1, - undefined, - UNDEFINED_NODE_ID, - 0x8d4b, - Buffer.alloc(0), - DECRYPT_KEY, - ENCRYPT_KEY, - Buffer.alloc(0), - async () => { + const secureSession1 = new SecureSession({ + context: {} as any, + id: 1, + fabric: undefined, + peerNodeId: UNDEFINED_NODE_ID, + peerSessionId: 0x8d4b, + decryptKey: DECRYPT_KEY, + encryptKey: ENCRYPT_KEY, + attestationKey: Buffer.alloc(0), + closeCallback: async () => { session1Destroyed = true; }, - ); + }); fabric.addSession(secureSession1); - const secureSession2 = new SecureSession( - {} as any, - 1, - undefined, - UNDEFINED_NODE_ID, - 0x8d4b, - Buffer.alloc(0), - DECRYPT_KEY, - ENCRYPT_KEY, - Buffer.alloc(0), - async () => { + const secureSession2 = new SecureSession({ + context: {} as any, + id: 2, + fabric: undefined, + peerNodeId: UNDEFINED_NODE_ID, + peerSessionId: 0x8d4b, + decryptKey: DECRYPT_KEY, + encryptKey: ENCRYPT_KEY, + attestationKey: Buffer.alloc(0), + closeCallback: async () => { session2Destroyed = true; }, - ); + }); fabric.addSession(secureSession2); fabric.removeSession(secureSession1); diff --git a/packages/matter-node.js/test/interaction/InteractionProtocolTest.ts b/packages/matter-node.js/test/interaction/InteractionProtocolTest.ts index 05be9cc704..555e16cec6 100644 --- a/packages/matter-node.js/test/interaction/InteractionProtocolTest.ts +++ b/packages/matter-node.js/test/interaction/InteractionProtocolTest.ts @@ -673,22 +673,22 @@ async function getDummyMessageExchange( timedInteractionExpired = false, clearTimedInteractionCallback?: () => void, ) { - const session = await SecureSession.create( - { getFabrics: () => [] } as any, - 1, - testFabric, - NodeId(1), - 1, - ByteArray.fromHex("00"), - ByteArray.fromHex("00"), - false, - false, - async () => { - /* nothing */ + const session = await SecureSession.create({ + context: { getFabrics: () => [] } as any, + id: 1, + fabric: testFabric, + peerNodeId: NodeId(BigInt(1)), + peerSessionId: 1, + sharedSecret: ByteArray.fromHex("00"), + salt: ByteArray.fromHex("00"), + isInitiator: false, + isResumption: false, + closeCallback: async () => { + /* */ }, - 1000, - 1000, - ); + idleRetransmissionTimeoutMs: 1000, + activeRetransmissionTimeoutMs: 1000, + }); return { channel: { name: "test" }, clearTimedInteraction: () => clearTimedInteractionCallback?.(), diff --git a/packages/matter-node.js/test/session/SecureSessionTest.ts b/packages/matter-node.js/test/session/SecureSessionTest.ts index 6d55c307a9..2a902ec5d2 100644 --- a/packages/matter-node.js/test/session/SecureSessionTest.ts +++ b/packages/matter-node.js/test/session/SecureSessionTest.ts @@ -44,20 +44,19 @@ const ENCRYPTED_BYTES = ByteArray.fromHex( ); describe("SecureSession", () => { - const secureSession = new SecureSession( - {} as any, - 1, - undefined, - UNDEFINED_NODE_ID, - 0x8d4b, - Buffer.alloc(0), - DECRYPT_KEY, - ENCRYPT_KEY, - Buffer.alloc(0), - async () => { + const secureSession = new SecureSession({ + context: {} as any, + id: 1, + fabric: undefined, + peerNodeId: UNDEFINED_NODE_ID, + peerSessionId: 0x8d4b, + decryptKey: DECRYPT_KEY, + encryptKey: ENCRYPT_KEY, + attestationKey: Buffer.alloc(0), + closeCallback: async () => { /* do nothing */ }, - ); + }); describe("decrypt", () => { it("decrypts a message", () => { diff --git a/packages/matter.js/src/CommissioningController.ts b/packages/matter.js/src/CommissioningController.ts index bb7f4080cc..951f636da1 100644 --- a/packages/matter.js/src/CommissioningController.ts +++ b/packages/matter.js/src/CommissioningController.ts @@ -122,6 +122,10 @@ export class CommissioningController extends MatterNode { super(); } + get nodeId() { + return this.controllerInstance?.nodeId; + } + assertIsAddedToMatterServer() { if (this.mdnsScanner === undefined || this.storage === undefined) { throw new ImplementationError("Add the node to the Matter instance before."); diff --git a/packages/matter.js/src/CommissioningServer.ts b/packages/matter.js/src/CommissioningServer.ts index b933e4bcc4..d48276d74e 100644 --- a/packages/matter.js/src/CommissioningServer.ts +++ b/packages/matter.js/src/CommissioningServer.ts @@ -180,6 +180,20 @@ export interface CommissioningServerOptions { allowCountryCodeChange?: boolean; // Default true if not set countryCodeWhitelist?: string[]; // Default all countries are allowed }; + + /** + * This callback is called when the device is commissioned or decommissioned to a fabric/controller. The provided + * fabricIndex can be used together with getCommissionedFabricInformation() to get more details about the fabric + * (or if this fabricIndex is missing it was deleted). + */ + commissioningChangedCallback?: (fabricIndex: FabricIndex) => void; + + /** + * This callback is called when sessions to the device are established, closed or subscriptions get added or + * removed. The provided fabricIndex can be used together with getActiveSessionInformation() to get more details + * about the open sessions and their status. + */ + activeSessionsChangedCallback?: (fabricIndex: FabricIndex) => void; } /** @@ -574,15 +588,28 @@ export class CommissioningServer extends MatterNode { this.discriminator, this.passcode, this.storage, - () => { - // When first Fabric is added (aka initial commissioning) and we did not advertised on MDNS before, add broadcaster now - // TODO Refactor this out when we remove MatterDevice class - if (limitTo !== undefined && !limitTo.onIpNetwork) { - if (this.mdnsInstanceBroadcaster !== undefined) { + (fabricIndex: FabricIndex) => { + const fabricsCount = this.deviceInstance?.getFabrics().length ?? 0; + if (fabricsCount === 1) { + // When first Fabric is added (aka initial commissioning) and we did not advertised on MDNS before, add broadcaster now + // TODO Refactor this out when we remove MatterDevice class + if ( + this.mdnsInstanceBroadcaster !== undefined && + !this.deviceInstance?.hasBroadcaster(this.mdnsInstanceBroadcaster) + ) { this.deviceInstance?.addBroadcaster(this.mdnsInstanceBroadcaster); } } + if (fabricsCount === 0) { + // When last fabric gets deleted we do a factory reset + this.factoryReset() + .then(() => this.options.commissioningChangedCallback?.(fabricIndex)) + .catch(error => logger.error("Error while doing factory reset of the device", error)); + } else { + this.options.commissioningChangedCallback?.(fabricIndex); + } }, + (fabricIndex: FabricIndex) => this.options.activeSessionsChangedCallback?.(fabricIndex), ) .addTransportInterface(await UdpInterface.create("udp6", this.port, this.options.listeningAddressIpv6)) .addScanner(this.mdnsScanner) @@ -908,15 +935,26 @@ export class CommissioningServer extends MatterNode { } } - /** Get some basic details of all Fabrics the server is commissioned to. */ - getCommissionedFabricInformation() { + /** + * Get some basic details of all Fabrics the server is commissioned to. + * + * @param fabricIndex Optional fabric index to filter for. If not set all fabrics are returned. + */ + getCommissionedFabricInformation(fabricIndex?: FabricIndex) { if (!this.isCommissioned()) return []; - return this.deviceInstance?.getFabrics().map(fabric => fabric.getExternalInformation()) ?? []; + const allFabrics = this.deviceInstance?.getFabrics() ?? []; + const fabrics = fabricIndex === undefined ? allFabrics : allFabrics.filter(f => f.fabricIndex === fabricIndex); + return fabrics.map(fabric => fabric.getExternalInformation()) ?? []; } - /** Get some basic details of all currently active sessions. */ - getActiveSessionInformation() { + /** + * Get some basic details of all currently active sessions. + * + * @param fabricIndex Optional fabric index to filter for. If not set all sessions are returned. + */ + getActiveSessionInformation(fabricIndex?: FabricIndex) { if (!this.isCommissioned()) return []; - return this.deviceInstance?.getActiveSessionInformation() ?? []; + const allSessions = this.deviceInstance?.getActiveSessionInformation() ?? []; + return allSessions.filter(({ fabric }) => fabricIndex === undefined || fabric?.fabricIndex === fabricIndex); } } diff --git a/packages/matter.js/src/MatterController.ts b/packages/matter.js/src/MatterController.ts index ce2f6b1f0b..a6fb102469 100644 --- a/packages/matter.js/src/MatterController.ts +++ b/packages/matter.js/src/MatterController.ts @@ -178,6 +178,10 @@ export class MatterController { this.addTransportInterface(netInterfaceIpv6); } + get nodeId() { + return this.fabric.rootNodeId; + } + public addTransportInterface(netInterface: NetInterface) { this.exchangeManager.addTransportInterface(netInterface); } @@ -569,10 +573,10 @@ export class MatterController { salt: ByteArray, isInitiator: boolean, isResumption: boolean, - idleRetransTimeoutMs?: number, - activeRetransTimeoutMs?: number, + idleRetransmissionTimeoutMs?: number, + activeRetransmissionTimeoutMs?: number, ) { - const session = await this.sessionManager.createSecureSession( + const session = await this.sessionManager.createSecureSession({ sessionId, fabric, peerNodeId, @@ -581,9 +585,9 @@ export class MatterController { salt, isInitiator, isResumption, - idleRetransTimeoutMs, - activeRetransTimeoutMs, - async () => { + idleRetransmissionTimeoutMs, + activeRetransmissionTimeoutMs, + closeCallback: async () => { logger.debug(`Remove ${session.isPase() ? "PASE" : "CASE"} session`, session.name); if (!session.closingAfterExchangeFinished) { // Delayed closing is executed when exchange is closed @@ -591,7 +595,7 @@ export class MatterController { } this.sessionClosedCallback?.(peerNodeId); }, - ); + }); return session; } diff --git a/packages/matter.js/src/MatterDevice.ts b/packages/matter.js/src/MatterDevice.ts index b88aca4815..833cd470ed 100644 --- a/packages/matter.js/src/MatterDevice.ts +++ b/packages/matter.js/src/MatterDevice.ts @@ -76,9 +76,14 @@ export class MatterDevice { private readonly discriminator: number, private readonly initialPasscode: number, private readonly storage: StorageContext, - private readonly initialCommissioningCallback: () => void, + private readonly commissioningChangedCallback: (fabricIndex: FabricIndex) => void, + private readonly sessionChangedCallback: (fabricIndex: FabricIndex) => void, ) { - this.fabricManager = new FabricManager(this.storage); + this.fabricManager = new FabricManager(this.storage, (fabricIndex: FabricIndex, peerNodeId: NodeId) => { + // When fabric is removed, also remove the resumption record + this.sessionManager.removeResumptionRecord(peerNodeId); + this.commissioningChangedCallback(fabricIndex); + }); this.sessionManager = new SessionManager(this, this.storage); this.sessionManager.initFromStorage(this.fabricManager.getFabrics()); @@ -95,6 +100,10 @@ export class MatterDevice { return this; } + hasBroadcaster(broadcaster: InstanceBroadcaster) { + return this.broadcasters.includes(broadcaster); + } + addBroadcaster(broadcaster: InstanceBroadcaster) { this.broadcasters.push(broadcaster); return this; @@ -150,7 +159,7 @@ export class MatterDevice { } const fabrics = this.fabricManager.getFabrics(); if (fabrics.length) { - let fabricsToAnnounce = 0; + let fabricsWithoutSessions = 0; for (const fabric of fabrics) { const session = this.sessionManager.getSessionForNode(fabric, fabric.rootNodeId); if ( @@ -158,13 +167,13 @@ export class MatterDevice { !session.isSecure() || (session as SecureSession).numberOfActiveSubscriptions === 0 ) { - fabricsToAnnounce++; + fabricsWithoutSessions++; logger.debug("Announcing", Logger.dict({ fabric: fabric.fabricId })); } } for (const broadcaster of this.broadcasters) { await broadcaster.setFabrics(fabrics); - if (fabricsToAnnounce > 0) { + if (fabricsWithoutSessions > 0) { await broadcaster.announce(); } } @@ -231,10 +240,10 @@ export class MatterDevice { salt: ByteArray, isInitiator: boolean, isResumption: boolean, - idleRetransTimeoutMs?: number, - activeRetransTimeoutMs?: number, + idleRetransmissionTimeoutMs?: number, + activeRetransmissionTimeoutMs?: number, ) { - const session = await this.sessionManager.createSecureSession( + const session = await this.sessionManager.createSecureSession({ sessionId, fabric, peerNodeId, @@ -243,9 +252,9 @@ export class MatterDevice { salt, isInitiator, isResumption, - idleRetransTimeoutMs, - activeRetransTimeoutMs, - async () => { + idleRetransmissionTimeoutMs, + activeRetransmissionTimeoutMs, + closeCallback: async () => { logger.debug(`Remove ${session.isPase() ? "PASE" : "CASE"} session`, session.name); if (session.isPase() && this.failSafeContext !== undefined) { await this.failSafeContext.expire(); @@ -254,10 +263,23 @@ export class MatterDevice { // Delayed closing is executed when exchange is closed await this.exchangeManager.closeSession(session); } - + const currentFabric = session.getFabric(); + if (currentFabric !== undefined) { + this.sessionChangedCallback(currentFabric.fabricIndex); + } await this.startAnnouncement(); }, - ); + subscriptionChangedCallback: () => { + const currentFabric = session.getFabric(); + logger.warn(`Session ${session.name} with fabric ${!!currentFabric}!`); + if (currentFabric !== undefined) { + this.sessionChangedCallback(currentFabric.fabricIndex); + } + }, + }); + if (fabric !== undefined) { + this.sessionChangedCallback(fabric.fabricIndex); + } return session; } @@ -268,6 +290,7 @@ export class MatterDevice { updateFabric(fabric: Fabric) { this.fabricManager.updateFabric(fabric); this.sessionManager.updateFabricForResumptionRecords(fabric); + this.commissioningChangedCallback(fabric.fabricIndex); } getNextFabricIndex() { @@ -282,14 +305,8 @@ export class MatterDevice { if (this.activeCommissioningMode !== AdministratorCommissioning.CommissioningWindowStatus.WindowNotOpen) { await this.endCommissioning(); } + this.commissioningChangedCallback(fabric.fabricIndex); const fabrics = this.fabricManager.getFabrics(); - logger.info("Added Fabric", Logger.dict({ fabric: fabric.fabricId })); - if (fabrics.length === 1) { - // Inform upper layer to add MDNS Broadcaster delayed if we limited announcements to BLE till now - // TODO Change when refactoring MatterDevice away - this.initialCommissioningCallback(); - } - logger.info("Send Fabric announcement", Logger.dict({ fabric: fabric.fabricId })); this.sendFabricAnnouncements(fabrics, true).catch(error => logger.warn(`Error sending Fabric announcement for Index ${fabric.fabricIndex}`, error), ); diff --git a/packages/matter.js/src/cluster/server/ClusterServer.ts b/packages/matter.js/src/cluster/server/ClusterServer.ts index 564fbf5bc6..41697f9f27 100644 --- a/packages/matter.js/src/cluster/server/ClusterServer.ts +++ b/packages/matter.js/src/cluster/server/ClusterServer.ts @@ -335,7 +335,7 @@ export function ClusterServer< : undefined, ); - // Add the relevant convenient methods to the CLusterServerObj + // Add the relevant convenient methods to the ClusterServerObj if (fixed) { result[`get${capitalizedAttributeName}Attribute`] = () => (attributes as any)[attributeName].getLocal(); } else if (fabricScoped) { diff --git a/packages/matter.js/src/cluster/server/OperationalCredentialsServer.ts b/packages/matter.js/src/cluster/server/OperationalCredentialsServer.ts index 31c0362479..6922fb590f 100644 --- a/packages/matter.js/src/cluster/server/OperationalCredentialsServer.ts +++ b/packages/matter.js/src/cluster/server/OperationalCredentialsServer.ts @@ -376,11 +376,6 @@ export const OperationalCredentialsClusterHandler: ( fabrics.updated(session); trustedRootCertificates.updated(session); - if (device.getFabrics().length === 0) { - // TODO If the FabricIndex matches the last remaining entry in the Fabrics list, then the device SHALL delete all Matter related data on the node which was created since it was commissioned. This includes all Fabric-Scoped data, including Access Control List, bindings, scenes, group keys, operational certificates, etc. All Trusted Roots SHALL also be removed. Any Matter related data including logs, secure sessions, exchanges and interaction model constructs SHALL also be removed. Since this operation involves the removal of the secure session data that may underpin the current set of exchanges, the Node invoking the command SHOULD NOT expect a response before terminating its secure session with the target. - // --> basically do a factory reset - } - return { statusCode: OperationalCredentials.NodeOperationalCertStatus.Ok, fabricIndex, diff --git a/packages/matter.js/src/fabric/Fabric.ts b/packages/matter.js/src/fabric/Fabric.ts index ee21c2e3b6..4097138264 100644 --- a/packages/matter.js/src/fabric/Fabric.ts +++ b/packages/matter.js/src/fabric/Fabric.ts @@ -171,10 +171,10 @@ export class Fabric { } async remove(currentSessionId?: number) { + this.removeCallbacks.forEach(callback => callback()); for (const session of [...this.sessions]) { await session.destroy(false, session.getId() === currentSessionId); // Delay Close for current session only } - this.removeCallbacks.forEach(callback => callback()); } persist() { @@ -223,6 +223,7 @@ export class Fabric { getExternalInformation() { return { + fabricIndex: this.fabricIndex, fabricId: this.fabricId, nodeId: this.nodeId, rootNodeId: this.rootNodeId, diff --git a/packages/matter.js/src/fabric/FabricManager.ts b/packages/matter.js/src/fabric/FabricManager.ts index 152cdc89fc..e9b0133957 100644 --- a/packages/matter.js/src/fabric/FabricManager.ts +++ b/packages/matter.js/src/fabric/FabricManager.ts @@ -6,6 +6,7 @@ import { InternalError, MatterError, MatterFlowError } from "../common/MatterError.js"; import { FabricIndex } from "../datatype/FabricIndex.js"; +import { NodeId } from "../datatype/NodeId.js"; import { StorageContext } from "../storage/StorageContext.js"; import { ByteArray } from "../util/ByteArray.js"; import { Fabric, FabricJsonObject } from "./Fabric.js"; @@ -19,7 +20,10 @@ export class FabricManager { private readonly fabrics = new Map(); private readonly fabricStorage: StorageContext; - constructor(storage: StorageContext) { + constructor( + storage: StorageContext, + private readonly fabricRemoveCallback?: (fabricIndex: FabricIndex, peerNodeId: NodeId) => void, + ) { this.fabricStorage = storage.createContext("FabricManager"); const fabrics = this.fabricStorage.get("fabrics", []); fabrics.forEach(fabric => this.addFabric(Fabric.createFromStorageObject(fabric))); @@ -56,12 +60,14 @@ export class FabricManager { } removeFabric(fabricIndex: FabricIndex) { - if (!this.fabrics.has(fabricIndex)) + const fabric = this.fabrics.get(fabricIndex); + if (fabric === undefined) throw new FabricNotFoundError( `Fabric with index ${fabricIndex} cannot be removed because it does not exist.`, ); this.fabrics.delete(fabricIndex); this.persistFabrics(); + this.fabricRemoveCallback?.(fabricIndex, fabric.rootNodeId); } getFabrics() { diff --git a/packages/matter.js/src/session/SecureSession.ts b/packages/matter.js/src/session/SecureSession.ts index f5e0c57dea..15c47fed26 100644 --- a/packages/matter.js/src/session/SecureSession.ts +++ b/packages/matter.js/src/session/SecureSession.ts @@ -36,21 +36,50 @@ export class SecureSession implements Session { activeTimestamp = this.timestamp; private _closingAfterExchangeFinished = false; private _sendCloseMessageWhenClosing = true; - - static async create( - context: T, - id: number, - fabric: Fabric | undefined, - peerNodeId: NodeId, - peerSessionId: number, - sharedSecret: ByteArray, - salt: ByteArray, - isInitiator: boolean, - isResumption: boolean, - closeCallback: () => Promise, - idleRetransTimeoutMs?: number, - activeRetransTimeoutMs?: number, - ) { + private readonly context: T; + private readonly id: number; + private fabric: Fabric | undefined; + private readonly peerNodeId: NodeId; + private readonly peerSessionId: number; + private readonly decryptKey: ByteArray; + private readonly encryptKey: ByteArray; + private readonly attestationKey: ByteArray; + private readonly closeCallback: () => Promise; + private readonly subscriptionChangedCallback: () => void; + private readonly idleRetransmissionTimeoutMs: number; + private readonly activeRetransmissionTimeoutMs: number; + private readonly retransmissionRetries: number; + + static async create(args: { + context: T; + id: number; + fabric: Fabric | undefined; + peerNodeId: NodeId; + peerSessionId: number; + sharedSecret: ByteArray; + salt: ByteArray; + isInitiator: boolean; + isResumption: boolean; + closeCallback: () => Promise; + subscriptionChangedCallback?: () => void; + idleRetransmissionTimeoutMs?: number; + activeRetransmissionTimeoutMs?: number; + }) { + const { + context, + id, + fabric, + peerNodeId, + peerSessionId, + sharedSecret, + salt, + isInitiator, + isResumption, + closeCallback, + idleRetransmissionTimeoutMs, + activeRetransmissionTimeoutMs, + subscriptionChangedCallback, + } = args; const keys = await Crypto.hkdf( sharedSecret, salt, @@ -60,37 +89,67 @@ export class SecureSession implements Session { const decryptKey = isInitiator ? keys.slice(16, 32) : keys.slice(0, 16); const encryptKey = isInitiator ? keys.slice(0, 16) : keys.slice(16, 32); const attestationKey = keys.slice(32, 48); - return new SecureSession( + return new SecureSession({ context, id, fabric, peerNodeId, peerSessionId, - sharedSecret, decryptKey, encryptKey, attestationKey, closeCallback, - idleRetransTimeoutMs, - activeRetransTimeoutMs, - ); + subscriptionChangedCallback, + idleRetransmissionTimeoutMs, + activeRetransmissionTimeoutMs, + }); } - constructor( - private readonly context: T, - private readonly id: number, - private fabric: Fabric | undefined, - private readonly peerNodeId: NodeId, - private readonly peerSessionId: number, - _sharedSecret: ByteArray, - private readonly decryptKey: ByteArray, - private readonly encryptKey: ByteArray, - private readonly attestationKey: ByteArray, - private readonly closeCallback: () => Promise, - private readonly idleRetransmissionTimeoutMs: number = DEFAULT_IDLE_RETRANSMISSION_TIMEOUT_MS, - private readonly activeRetransmissionTimeoutMs: number = DEFAULT_ACTIVE_RETRANSMISSION_TIMEOUT_MS, - private readonly retransmissionRetries: number = DEFAULT_RETRANSMISSION_RETRIES, - ) { + constructor(args: { + context: T; + id: number; + fabric: Fabric | undefined; + peerNodeId: NodeId; + peerSessionId: number; + decryptKey: ByteArray; + encryptKey: ByteArray; + attestationKey: ByteArray; + closeCallback: () => Promise; + subscriptionChangedCallback?: () => void; + idleRetransmissionTimeoutMs?: number; + activeRetransmissionTimeoutMs?: number; + retransmissionRetries?: number; + }) { + const { + context, + id, + fabric, + peerNodeId, + peerSessionId, + decryptKey, + encryptKey, + attestationKey, + closeCallback, + subscriptionChangedCallback = () => {}, + idleRetransmissionTimeoutMs = DEFAULT_IDLE_RETRANSMISSION_TIMEOUT_MS, + activeRetransmissionTimeoutMs = DEFAULT_ACTIVE_RETRANSMISSION_TIMEOUT_MS, + retransmissionRetries = DEFAULT_RETRANSMISSION_RETRIES, + } = args; + + this.context = context; + this.id = id; + this.fabric = fabric; + this.peerNodeId = peerNodeId; + this.peerSessionId = peerSessionId; + this.decryptKey = decryptKey; + this.encryptKey = encryptKey; + this.attestationKey = attestationKey; + this.closeCallback = closeCallback; + this.subscriptionChangedCallback = subscriptionChangedCallback; + this.idleRetransmissionTimeoutMs = idleRetransmissionTimeoutMs; + this.activeRetransmissionTimeoutMs = activeRetransmissionTimeoutMs; + this.retransmissionRetries = retransmissionRetries; + fabric?.addSession(this); logger.debug( @@ -208,6 +267,7 @@ export class SecureSession implements Session { addSubscription(subscription: SubscriptionHandler) { this.subscriptions.push(subscription); logger.debug(`Added subscription ${subscription.subscriptionId} to ${this.name}/${this.id}`); + this.subscriptionChangedCallback(); } get numberOfActiveSubscriptions() { @@ -219,6 +279,7 @@ export class SecureSession implements Session { if (index !== -1) { this.subscriptions.splice(index, 1); logger.debug(`Removed subscription ${subscriptionId} from ${this.name}/${this.id}`); + this.subscriptionChangedCallback(); } } diff --git a/packages/matter.js/src/session/SessionManager.ts b/packages/matter.js/src/session/SessionManager.ts index c0eff8ebb1..047baaf418 100644 --- a/packages/matter.js/src/session/SessionManager.ts +++ b/packages/matter.js/src/session/SessionManager.ts @@ -53,21 +53,21 @@ export class SessionManager { this.sessions.set(UNICAST_UNSECURE_SESSION_ID, this.unsecureSession); } - async createSecureSession( - sessionId: number, - fabric: Fabric | undefined, - peerNodeId: NodeId, - peerSessionId: number, - sharedSecret: ByteArray, - salt: ByteArray, - isInitiator: boolean, - isResumption: boolean, - idleRetransTimeoutMs?: number, - activeRetransTimeoutMs?: number, - closeCallback?: () => Promise, - ) { - const session = await SecureSession.create( - this.context, + async createSecureSession(args: { + sessionId: number; + fabric: Fabric | undefined; + peerNodeId: NodeId; + peerSessionId: number; + sharedSecret: ByteArray; + salt: ByteArray; + isInitiator: boolean; + isResumption: boolean; + idleRetransmissionTimeoutMs?: number; + activeRetransmissionTimeoutMs?: number; + closeCallback?: () => Promise; + subscriptionChangedCallback?: () => void; + }) { + const { sessionId, fabric, peerNodeId, @@ -76,14 +76,30 @@ export class SessionManager { salt, isInitiator, isResumption, - async () => { + idleRetransmissionTimeoutMs, + activeRetransmissionTimeoutMs, + closeCallback, + subscriptionChangedCallback, + } = args; + const session = await SecureSession.create({ + context: this.context, + id: sessionId, + fabric, + peerNodeId, + peerSessionId, + sharedSecret, + salt, + isInitiator, + isResumption, + closeCallback: async () => { logger.info(`Remove Session ${session.name} from session manager.`); await closeCallback?.(); this.sessions.delete(sessionId); }, - idleRetransTimeoutMs, - activeRetransTimeoutMs, - ); + idleRetransmissionTimeoutMs, + activeRetransmissionTimeoutMs, + subscriptionChangedCallback: () => subscriptionChangedCallback?.(), + }); this.sessions.set(sessionId, session); // TODO: Add a maximum of sessions and respect/close the "least recently used" session. See Core Specs 4.10.1.1