Implement MTRDevice handling in controller suspend/resume. (#35464)
Also blocks acquisition of CASE sessions on a suspended controller, which should
ensure that new requests for MTRDevices and MTRBaseDevices associated with the
controller do not hit the network.
diff --git a/src/darwin/Framework/CHIP/MTRDevice.mm b/src/darwin/Framework/CHIP/MTRDevice.mm
index ceca6d2..a668e02 100644
--- a/src/darwin/Framework/CHIP/MTRDevice.mm
+++ b/src/darwin/Framework/CHIP/MTRDevice.mm
@@ -648,8 +648,9 @@
- (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<MTRDeviceDelegate>)delegate
@@ -1743,6 +1744,16 @@
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 b743b9f..a55946c 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceController.mm
@@ -120,7 +120,6 @@
MTROperationalCredentialsDelegate * _operationalCredentialsDelegate;
MTRDeviceAttestationDelegateBridge * _deviceAttestationDelegateBridge;
MTRDeviceControllerFactory * _factory;
- NSMapTable * _nodeIDToDeviceMap;
os_unfair_lock _underlyingDeviceMapLock;
MTRCommissionableBrowser * _commissionableBrowser;
MTRAttestationTrustStoreBridge * _attestationTrustStoreBridge;
@@ -135,6 +134,7 @@
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 @@
_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 @@
_assertionLock = OS_UNFAIR_LOCK_INIT;
_suspended = startSuspended;
+ _suspensionLock = OS_UNFAIR_LOCK_INIT;
if (storageDelegate != nil) {
if (storageDelegateQueue == nil) {
@@ -350,23 +357,46 @@
- (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];
+ }
- // TODO: What should happen with active commissioning sessions? Presumably
- // close them?
+ for (MTRDevice * device in devices) {
+ [device controllerSuspended];
+ }
+
+ // 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 68d725f..171c91c 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h
+++ b/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h
@@ -150,6 +150,11 @@
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 fa2791a..6a64b6e 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm
@@ -338,6 +338,9 @@
@end
@implementation MTRDeviceControllerExternalCertificateParameters
+
+@dynamic rootCertificate;
+
- (instancetype)initWithStorageDelegate:(id<MTRDeviceControllerStorageDelegate>)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 fba26d9..fbfc072 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm
@@ -288,8 +288,6 @@
_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 @@
- (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)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 5d5acf8..643a232 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h
+++ b/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h
@@ -66,7 +66,7 @@
@interface MTRDeviceController ()
-@property (nonatomic, readwrite, nullable) NSMapTable * nodeIDToDeviceMap;
+@property (nonatomic, readonly) NSMapTable<NSNumber *, MTRDevice *> * 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 1bf0888..cb09fbd 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm
@@ -113,7 +113,6 @@
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 52acb7c..b38f381 100644
--- a/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm
+++ b/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm
@@ -724,14 +724,23 @@
{
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 @@
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 @@
{
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 @@
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 9627d4f..9905da7 100644
--- a/src/darwin/Framework/CHIP/MTRDevice_Internal.h
+++ b/src/darwin/Framework/CHIP/MTRDevice_Internal.h
@@ -196,6 +196,9 @@
- (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 @@
// Used to generate attribute report that contains all known attributes, taking into consideration expected values
- (NSArray<NSDictionary<NSString *, id> *> *)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 35df347..ccce347 100644
--- a/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
+++ b/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
@@ -262,11 +262,8 @@
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,28 +292,9 @@
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];
@@ -327,6 +305,52 @@
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
+ nodeID:(NSNumber *)nodeID
+ storage:(MTRTestPerControllerStorage *)storage
caseAuthenticatedTags:(nullable NSSet *)caseAuthenticatedTags
error:(NSError * __autoreleasing *)error
certificateIssuer:
@@ -462,6 +486,7 @@
XCTAssertNil(error);
XCTAssertNotNil(controller);
XCTAssertTrue([controller isRunning]);
+ XCTAssertFalse(controller.suspended);
XCTAssertEqualObjects(controller.controllerNodeID, nodeID);
@@ -1611,6 +1636,128 @@
[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. Clear the delegate's onReportEnd
+ // first, so reports from the command don't trigger it.
+ delegate.onReportEnd = nil;
+ 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. Clear the delegate's onReportEnd
+ // first, so reports from the command don't trigger it.
+ delegate.onReportEnd = nil;
+ 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.