diff --git a/src/darwin/Framework/CHIP/MTRDevice.mm b/src/darwin/Framework/CHIP/MTRDevice.mm index ceca6d27830f8c..a668e02bbfa1ff 100644 --- a/src/darwin/Framework/CHIP/MTRDevice.mm +++ b/src/darwin/Framework/CHIP/MTRDevice.mm @@ -648,8 +648,9 @@ - (void)_addDelegate:(id)delegate queue:(dispatch_queue_t)que - (void)_delegateAdded { - // Nothing to do; this is a hook for subclasses. If that ever changes for - // some reason, subclasses need to start calling this hook on their super. + os_unfair_lock_assert_owner(&self->_lock); + + // Nothing to do for now. At the moment this is a hook for subclasses. } - (void)removeDelegate:(id)delegate @@ -1743,6 +1744,16 @@ - (NSNumber * _Nullable)_networkFeatures return result; } +- (void)controllerSuspended +{ + // Nothing to do for now. +} + +- (void)controllerResumed +{ + // Nothing to do for now. +} + @end /* BEGIN DRAGONS: Note methods here cannot be renamed, and are used by private callers, do not rename, remove or modify behavior here */ diff --git a/src/darwin/Framework/CHIP/MTRDeviceController.mm b/src/darwin/Framework/CHIP/MTRDeviceController.mm index b743b9fb6fabad..a55946c237c364 100644 --- a/src/darwin/Framework/CHIP/MTRDeviceController.mm +++ b/src/darwin/Framework/CHIP/MTRDeviceController.mm @@ -120,7 +120,6 @@ @implementation MTRDeviceController { MTROperationalCredentialsDelegate * _operationalCredentialsDelegate; MTRDeviceAttestationDelegateBridge * _deviceAttestationDelegateBridge; MTRDeviceControllerFactory * _factory; - NSMapTable * _nodeIDToDeviceMap; os_unfair_lock _underlyingDeviceMapLock; MTRCommissionableBrowser * _commissionableBrowser; MTRAttestationTrustStoreBridge * _attestationTrustStoreBridge; @@ -135,6 +134,7 @@ @implementation MTRDeviceController { MTRP256KeypairBridge _operationalKeypairBridge; BOOL _suspended; + os_unfair_lock _suspensionLock; // Counters to track assertion status and access controlled by the _assertionLock NSUInteger _keepRunningAssertionCounter; @@ -160,6 +160,12 @@ - (instancetype)initForSubclasses:(BOOL)startSuspended _assertionLock = OS_UNFAIR_LOCK_INIT; _suspended = startSuspended; + // All synchronous suspend/resume activity has to be protected by + // _suspensionLock, so that parts of suspend/resume can't interleave with + // each other. + _suspensionLock = OS_UNFAIR_LOCK_INIT; + + _nodeIDToDeviceMap = [NSMapTable strongToWeakObjectsMapTable]; return self; } @@ -204,6 +210,7 @@ - (instancetype)initWithFactory:(MTRDeviceControllerFactory *)factory _assertionLock = OS_UNFAIR_LOCK_INIT; _suspended = startSuspended; + _suspensionLock = OS_UNFAIR_LOCK_INIT; if (storageDelegate != nil) { if (storageDelegateQueue == nil) { @@ -350,23 +357,46 @@ - (BOOL)isSuspended - (void)suspend { + MTR_LOG("%@ suspending", self); + + std::lock_guard lock(_suspensionLock); + _suspended = YES; - // TODO: In the concrete class (which is unused so far!), iterate our - // MTRDevices, tell them to tear down subscriptions. Possibly close all - // CASE sessions for our identity. Possibly try to see whether we can - // change our fabric entry to not advertise and restart advertising. + NSEnumerator * devices; + { + std::lock_guard lock(*self.deviceMapLock); + devices = [self.nodeIDToDeviceMap objectEnumerator]; + } + + for (MTRDevice * device in devices) { + [device controllerSuspended]; + } - // TODO: What should happen with active commissioning sessions? Presumably - // close them? + // TODO: In the concrete class, consider what should happen with: + // + // * Active commissioning sessions (presumably close them?) + // * CASE sessions in general. + // * Possibly try to see whether we can change our fabric entry to not advertise and restart advertising. } - (void)resume { + MTR_LOG("%@ resuming", self); + + std::lock_guard lock(_suspensionLock); + _suspended = NO; - // TODO: In the concrete class (which is unused so far!), iterate our - // MTRDevices, tell them to restart subscriptions. + NSEnumerator * devices; + { + std::lock_guard lock(*self.deviceMapLock); + devices = [self.nodeIDToDeviceMap objectEnumerator]; + } + + for (MTRDevice * device in devices) { + [device controllerResumed]; + } } - (BOOL)matchesPendingShutdownControllerWithOperationalCertificate:(nullable MTRCertificateDERBytes)operationalCertificate andRootCertificate:(nullable MTRCertificateDERBytes)rootCertificate diff --git a/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h b/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h index 68d725f796a0dd..171c91c889a493 100644 --- a/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h +++ b/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h @@ -150,6 +150,11 @@ MTR_AVAILABLE(ios(17.6), macos(14.6), watchos(10.6), tvos(17.6)) intermediateCertificate:(MTRCertificateDERBytes _Nullable)intermediateCertificate rootCertificate:(MTRCertificateDERBytes)rootCertificate; +/** + * The root certificate we were initialized with. + */ +@property (nonatomic, copy, readonly) MTRCertificateDERBytes rootCertificate MTR_NEWLY_AVAILABLE; + @end MTR_NEWLY_AVAILABLE diff --git a/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm b/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm index fa2791a908cda7..6a64b6ee84ac8d 100644 --- a/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm +++ b/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm @@ -338,6 +338,9 @@ + (nullable NSData *)publicKeyFromCertificate:(MTRCertificateDERBytes)certificat @end @implementation MTRDeviceControllerExternalCertificateParameters + +@dynamic rootCertificate; + - (instancetype)initWithStorageDelegate:(id)storageDelegate storageDelegateQueue:(dispatch_queue_t)storageDelegateQueue uniqueIdentifier:(NSUUID *)uniqueIdentifier diff --git a/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm b/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm index fba26d9ba79e03..fbfc072826ad8a 100644 --- a/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm +++ b/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm @@ -288,8 +288,6 @@ - (instancetype)initWithFactory:(MTRDeviceControllerFactory *)factory _otaProviderDelegateQueue = otaProviderDelegateQueue; _chipWorkQueue = queue; _factory = factory; - // TODO: Shouldn't nodeIDToDeviceMap just be set up by initForSubclasses? - self.nodeIDToDeviceMap = [NSMapTable strongToWeakObjectsMapTable]; _serverEndpoints = [[NSMutableArray alloc] init]; _commissionableBrowser = nil; @@ -1416,6 +1414,15 @@ - (BOOL)checkIsRunning:(NSError * __autoreleasing *)error - (void)getSessionForNode:(chip::NodeId)nodeID completion:(MTRInternalDeviceConnectionCallback)completion { + // TODO: Figure out whether the synchronization here makes sense. What + // happens if this call happens mid-suspend or mid-resume? + if (self.suspended) { + MTR_LOG_ERROR("%@ suspended: can't get session for node %016llX-%016llx (%llu)", self, self.compressedFabricID.unsignedLongLongValue, nodeID, nodeID); + // TODO: Can we do a better error here? + completion(nullptr, chip::NullOptional, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INCORRECT_STATE], nil); + return; + } + // Get the corresponding MTRDevice object to determine if the case/subscription pool is to be used MTRDevice * device = [self deviceForNodeID:@(nodeID)]; @@ -1440,6 +1447,15 @@ - (void)getSessionForNode:(chip::NodeId)nodeID completion:(MTRInternalDeviceConn - (void)directlyGetSessionForNode:(chip::NodeId)nodeID completion:(MTRInternalDeviceConnectionCallback)completion { + // TODO: Figure out whether the synchronization here makes sense. What + // happens if this call happens mid-suspend or mid-resume? + if (self.suspended) { + MTR_LOG_ERROR("%@ suspended: can't get session for node %016llX-%016llx (%llu)", self, self.compressedFabricID.unsignedLongLongValue, nodeID, nodeID); + // TODO: Can we do a better error here? + completion(nullptr, chip::NullOptional, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INCORRECT_STATE], nil); + return; + } + [self asyncGetCommissionerOnMatterQueue:^(chip::Controller::DeviceCommissioner * commissioner) { auto connectionBridge = new MTRDeviceConnectionBridge(completion); diff --git a/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h b/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h index 5d5acf8063d4c2..643a23225774fd 100644 --- a/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h +++ b/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h @@ -66,7 +66,7 @@ NS_ASSUME_NONNULL_BEGIN @interface MTRDeviceController () -@property (nonatomic, readwrite, nullable) NSMapTable * nodeIDToDeviceMap; +@property (nonatomic, readonly) NSMapTable * nodeIDToDeviceMap; @property (readonly, assign) os_unfair_lock_t deviceMapLock; // queue used to serialize all work performed by the MTRDeviceController diff --git a/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm b/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm index 1bf0888ab3b2b4..cb09fbd7ff737b 100644 --- a/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm +++ b/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm @@ -113,7 +113,6 @@ - (nullable instancetype)initWithParameters:(MTRDeviceControllerAbstractParamete self.xpcConnection = connectionBlock(); self.uniqueIdentifier = UUID; self.chipWorkQueue = dispatch_queue_create("MTRDeviceController_XPC_queue", DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL); - self.nodeIDToDeviceMap = [NSMapTable strongToWeakObjectsMapTable]; MTR_LOG("Set up XPC Connection: %@", self.xpcConnection); if (self.xpcConnection) { diff --git a/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm b/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm index 52acb7c0b4c09e..b38f3815f731c3 100644 --- a/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm +++ b/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm @@ -724,14 +724,23 @@ - (BOOL)_subscriptionsAllowed { os_unfair_lock_assert_owner(&self->_lock); - // We should not allow a subscription for device controllers over XPC. - return ![_deviceController isKindOfClass:MTRDeviceControllerOverXPC.class]; + // We should not allow a subscription for suspended controllers or device controllers over XPC. + return _deviceController.suspended == NO && ![_deviceController isKindOfClass:MTRDeviceControllerOverXPC.class]; } - (void)_delegateAdded { os_unfair_lock_assert_owner(&self->_lock); + [super _delegateAdded]; + + [self _ensureSubscriptionForExistingDelegates:@"delegate is set"]; +} + +- (void)_ensureSubscriptionForExistingDelegates:(NSString *)reason +{ + os_unfair_lock_assert_owner(&self->_lock); + __block BOOL shouldSetUpSubscription = [self _subscriptionsAllowed]; // For unit testing only. If this ever changes to not being for unit testing purposes, @@ -754,10 +763,10 @@ - (void)_delegateAdded MTR_LOG(" => %@ - device is a thread device, scheduling in pool", self); [self _scheduleSubscriptionPoolWork:^{ std::lock_guard lock(self->_lock); - [self _setupSubscriptionWithReason:@"delegate is set and scheduled subscription is happening"]; + [self _setupSubscriptionWithReason:[NSString stringWithFormat:@"%@ and scheduled subscription is happening", reason]]; } inNanoseconds:0 description:@"MTRDevice setDelegate first subscription"]; } else { - [self _setupSubscriptionWithReason:@"delegate is set and subscription is needed"]; + [self _setupSubscriptionWithReason:[NSString stringWithFormat:@"%@ and subscription is needed", reason]]; } } } @@ -1243,6 +1252,11 @@ - (void)_doHandleSubscriptionReset:(NSNumber * _Nullable)retryDelay { os_unfair_lock_assert_owner(&_lock); + if (_deviceController.suspended) { + MTR_LOG("%@ ignoring expected subscription reset on controller suspend", self); + return; + } + // If we are here, then either we failed to establish initial CASE, or we // failed to send the initial SubscribeRequest message, or our ReadClient // has given up completely. Those all count as "we have tried and failed to @@ -4002,6 +4016,34 @@ - (NSNumber * _Nullable)_networkFeatures return result; } +- (void)controllerSuspended +{ + [super controllerSuspended]; + + std::lock_guard lock(self->_lock); + [self _resetSubscriptionWithReasonString:@"Controller suspended"]; + + // Ensure that any pre-existing resubscribe attempts we control don't try to + // do anything. + _reattemptingSubscription = NO; +} + +- (void)controllerResumed +{ + [super controllerResumed]; + + std::lock_guard lock(self->_lock); + + if (![self _delegateExists]) { + MTR_LOG("%@ ignoring controller resume: no delegates", self); + return; + } + + // Use _ensureSubscriptionForExistingDelegates so that the subscriptions + // will go through the pool as needed, not necessarily happen immediately. + [self _ensureSubscriptionForExistingDelegates:@"Controller resumed"]; +} + @end /* BEGIN DRAGONS: Note methods here cannot be renamed, and are used by private callers, do not rename, remove or modify behavior here */ diff --git a/src/darwin/Framework/CHIP/MTRDevice_Internal.h b/src/darwin/Framework/CHIP/MTRDevice_Internal.h index 9627d4fccf56b8..9905da7ff711a6 100644 --- a/src/darwin/Framework/CHIP/MTRDevice_Internal.h +++ b/src/darwin/Framework/CHIP/MTRDevice_Internal.h @@ -196,6 +196,9 @@ MTR_DIRECT_MEMBERS - (BOOL)_delegateExists; +// Must be called by subclasses or MTRDevice implementation only. +- (void)_delegateAdded; + #ifdef DEBUG // Only used for unit test purposes - normal delegate should not expect or handle being called back synchronously // Returns YES if a delegate is called @@ -205,6 +208,10 @@ MTR_DIRECT_MEMBERS // Used to generate attribute report that contains all known attributes, taking into consideration expected values - (NSArray *> *)getAllAttributesReport; +// Hooks for controller suspend/resume. +- (void)controllerSuspended; +- (void)controllerResumed; + @end #pragma mark - MTRDevice internal state monitoring diff --git a/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m b/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m index 35df3471e43338..0d8deb3b1cff48 100644 --- a/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m +++ b/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m @@ -262,11 +262,8 @@ - (nullable MTRDeviceController *)startControllerWithRootKeys:(MTRTestKeys *)roo nodeID:(NSNumber *)nodeID storage:(MTRTestPerControllerStorage *)storage caseAuthenticatedTags:(NSSet * _Nullable)caseAuthenticatedTags + paramsModifier:(void (^_Nullable)(MTRDeviceControllerExternalCertificateParameters *))paramsModifier error:(NSError * __autoreleasing *)error - certificateIssuer: - (MTRPerControllerStorageTestsCertificateIssuer * __autoreleasing *)certificateIssuer - concurrentSubscriptionPoolSize:(NSUInteger)concurrentSubscriptionPoolSize - storageBehaviorConfiguration:(MTRDeviceStorageBehaviorConfiguration * _Nullable)storageBehaviorConfiguration { XCTAssertTrue(error != NULL); @@ -295,33 +292,60 @@ - (nullable MTRDeviceController *)startControllerWithRootKeys:(MTRTestKeys *)roo intermediateCertificate:nil rootCertificate:root]; XCTAssertNotNil(params); - // TODO: This is only used by testControllerServer. If that moves - // elsewhere, take this back out again. - params.shouldAdvertiseOperational = YES; - - __auto_type * ourCertificateIssuer = [[MTRPerControllerStorageTestsCertificateIssuer alloc] initWithRootCertificate:root - intermediateCertificate:nil - signingKey:rootKeys - fabricID:fabricID]; - XCTAssertNotNil(ourCertificateIssuer); - - if (certificateIssuer) { - *certificateIssuer = ourCertificateIssuer; - } - - [params setOperationalCertificateIssuer:ourCertificateIssuer queue:dispatch_get_main_queue()]; - if (concurrentSubscriptionPoolSize > 0) { - params.concurrentSubscriptionEstablishmentsAllowedOnThread = concurrentSubscriptionPoolSize; - } - - if (storageBehaviorConfiguration) { - params.storageBehaviorConfiguration = storageBehaviorConfiguration; + if (paramsModifier) { + paramsModifier(params); } return [[MTRDeviceController alloc] initWithParameters:params error:error]; } +- (nullable MTRDeviceController *)startControllerWithRootKeys:(MTRTestKeys *)rootKeys + operationalKeys:(MTRTestKeys *)operationalKeys + fabricID:(NSNumber *)fabricID + nodeID:(NSNumber *)nodeID + storage:(MTRTestPerControllerStorage *)storage + caseAuthenticatedTags:(NSSet * _Nullable)caseAuthenticatedTags + error:(NSError * __autoreleasing *)error + certificateIssuer: + (MTRPerControllerStorageTestsCertificateIssuer * __autoreleasing *)certificateIssuer + concurrentSubscriptionPoolSize:(NSUInteger)concurrentSubscriptionPoolSize + storageBehaviorConfiguration:(MTRDeviceStorageBehaviorConfiguration * _Nullable)storageBehaviorConfiguration +{ + return [self startControllerWithRootKeys:rootKeys + operationalKeys:operationalKeys + fabricID:fabricID + nodeID:nodeID + storage:storage + caseAuthenticatedTags:caseAuthenticatedTags + paramsModifier:^(MTRDeviceControllerExternalCertificateParameters * params) { + // TODO: This is only used by testControllerServer. If that moves + // elsewhere, take this back out again. + params.shouldAdvertiseOperational = YES; + + __auto_type * ourCertificateIssuer = [[MTRPerControllerStorageTestsCertificateIssuer alloc] initWithRootCertificate:params.rootCertificate + intermediateCertificate:nil + signingKey:rootKeys + fabricID:fabricID]; + XCTAssertNotNil(ourCertificateIssuer); + + if (certificateIssuer) { + *certificateIssuer = ourCertificateIssuer; + } + + [params setOperationalCertificateIssuer:ourCertificateIssuer queue:dispatch_get_main_queue()]; + + if (concurrentSubscriptionPoolSize > 0) { + params.concurrentSubscriptionEstablishmentsAllowedOnThread = concurrentSubscriptionPoolSize; + } + + if (storageBehaviorConfiguration) { + params.storageBehaviorConfiguration = storageBehaviorConfiguration; + } + } + error:error]; +} + - (nullable MTRDeviceController *)startControllerWithRootKeys:(MTRTestKeys *)rootKeys operationalKeys:(MTRTestKeys *)operationalKeys fabricID:(NSNumber *)fabricID @@ -462,6 +486,7 @@ - (void)test001_BasicControllerStartup XCTAssertNil(error); XCTAssertNotNil(controller); XCTAssertTrue([controller isRunning]); + XCTAssertFalse(controller.suspended); XCTAssertEqualObjects(controller.controllerNodeID, nodeID); @@ -1611,6 +1636,124 @@ - (void)test011_TestDataStoreMTRDeviceWithStorageBehaviorOptimizationDisabled [self doDataStoreMTRDeviceTestWithStorageDelegate:[[MTRTestPerControllerStorage alloc] initWithControllerID:[NSUUID UUID]] disableStorageBehaviorOptimization:YES]; } +// TODO: Factor out startControllerWithRootKeys into a test helper, move these +// suspension tests to a different file. +- (void)test012_startSuspended +{ + NSError * error; + __auto_type * storageDelegate = [[MTRTestPerControllerStorage alloc] initWithControllerID:[NSUUID UUID]]; + __auto_type * controller = [self startControllerWithRootKeys:[[MTRTestKeys alloc] init] + operationalKeys:[[MTRTestKeys alloc] init] + fabricID:@555 + nodeID:@888 + storage:storageDelegate + caseAuthenticatedTags:nil + paramsModifier:^(MTRDeviceControllerExternalCertificateParameters * params) { + params.startSuspended = YES; + } + error:&error]; + + XCTAssertNil(error); + XCTAssertNotNil(controller); + XCTAssertTrue(controller.running); + XCTAssertTrue(controller.suspended); + [controller shutdown]; +} + +- (void)test013_suspendDevices +{ + NSNumber * deviceID = @(17); + __auto_type * device = [self getMTRDevice:deviceID]; + __auto_type * controller = device.deviceController; + + XCTAssertFalse(controller.suspended); + + __auto_type queue = dispatch_get_main_queue(); + __auto_type * delegate = [[MTRDeviceTestDelegate alloc] init]; + + XCTestExpectation * initialSubscriptionExpectation = [self expectationWithDescription:@"Subscription has been set up"]; + XCTestExpectation * initialReachableExpectation = [self expectationWithDescription:@"Device initially became reachable"]; + XCTestExpectation * initialUnreachableExpectation = [self expectationWithDescription:@"Device initially became unreachable"]; + initialUnreachableExpectation.inverted = YES; + + delegate.onReachable = ^{ + [initialReachableExpectation fulfill]; + }; + + delegate.onNotReachable = ^{ + // We do not expect to land here. + [initialUnreachableExpectation fulfill]; + }; + + delegate.onReportEnd = ^{ + [initialSubscriptionExpectation fulfill]; + }; + + [device setDelegate:delegate queue:queue]; + [self waitForExpectations:@[ initialReachableExpectation, initialSubscriptionExpectation ] timeout:60]; + // Separately wait for the unreachable bit, so we don't end up waiting 60 + // seconds for it. + [self waitForExpectations:@[ initialUnreachableExpectation ] timeout:0]; + + // Test that sending a command works. + XCTestExpectation * toggle1Expectation = [self expectationWithDescription:@"toggle 1"]; + __auto_type * cluster = [[MTRClusterOnOff alloc] initWithDevice:device endpointID:@(1) queue:queue]; + [cluster toggleWithExpectedValues:nil expectedValueInterval:nil completion:^(NSError * _Nullable error) { + XCTAssertNil(error); + [toggle1Expectation fulfill]; + }]; + + [self waitForExpectations:@[ toggle1Expectation ] timeout:kTimeoutInSeconds]; + + XCTestExpectation * becameUnreachableExpectation = [self expectationWithDescription:@"Device became unreachable"]; + delegate.onNotReachable = ^{ + [becameUnreachableExpectation fulfill]; + }; + + [controller suspend]; + XCTAssertTrue(controller.suspended); + + // Test that sending a command no longer works. + XCTestExpectation * toggle2Expectation = [self expectationWithDescription:@"toggle 2"]; + [cluster toggleWithExpectedValues:nil expectedValueInterval:nil completion:^(NSError * _Nullable error) { + XCTAssertNotNil(error); + [toggle2Expectation fulfill]; + }]; + + [self waitForExpectations:@[ becameUnreachableExpectation, toggle2Expectation ] timeout:kTimeoutInSeconds]; + + XCTestExpectation * newSubscriptionExpectation = [self expectationWithDescription:@"Subscription has been set up again"]; + XCTestExpectation * newReachableExpectation = [self expectationWithDescription:@"Device became reachable again"]; + delegate.onReachable = ^{ + [newReachableExpectation fulfill]; + }; + + delegate.onReportEnd = ^{ + [newSubscriptionExpectation fulfill]; + }; + + [controller resume]; + XCTAssertFalse(controller.suspended); + + [self waitForExpectations:@[ newSubscriptionExpectation, newReachableExpectation ] timeout:kTimeoutInSeconds]; + + // Test that sending a command works again. + XCTestExpectation * toggle3Expectation = [self expectationWithDescription:@"toggle 3"]; + [cluster toggleWithExpectedValues:nil expectedValueInterval:nil completion:^(NSError * _Nullable error) { + XCTAssertNil(error); + [toggle3Expectation fulfill]; + }]; + + [self waitForExpectations:@[ toggle3Expectation ] timeout:kTimeoutInSeconds]; + + [controller removeDevice:device]; + // Reset our commissionee. + __auto_type * baseDevice = [MTRBaseDevice deviceWithNodeID:deviceID controller:controller]; + ResetCommissionee(baseDevice, queue, self, kTimeoutInSeconds); + + [controller shutdown]; +} + // TODO: This might want to go in a separate test file, with some shared setup // across multiple tests, maybe. Would need to factor out // startControllerWithRootKeys into a test helper.