diff --git a/src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.h b/src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.h new file mode 100644 index 00000000000000..142047e0ff3a7e --- /dev/null +++ b/src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.h @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 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 + * + * http://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. + */ + +#import +#import // For MTRCommandPath + +NS_ASSUME_NONNULL_BEGIN + +/** + * An object representing a single command to be invoked and the expected + * result of invoking it. + */ +// TODO: Maybe MTRCommandToInvoke? What's a good name here? +MTR_AVAILABLE(ios(18.4), macos(15.4), watchos(11.4), tvos(18.4)) +@interface MTRCommandWithExpectedResult : NSObject + +/** + * The path of the command being invoked. + */ +@property (nonatomic, retain) MTRCommandPath * path; + +/** + * The command fields to pass for the command invoke. nil if this command does + * not have any fields. If not nil, this should be a data-value dictionary of + * MTRStructureValueType. + */ +@property (nonatomic, retain, nullable) NSDictionary * commandFields; + +/** + * The expected result of invoking the command. + * + * If this is nil, that indicates that the invoke is considered successful if it + * does not result in an error status response. + * + * If this is is not nil, then invoke is considered successful if + * it results in a data response and for each entry in the provided + * expectedResult the field whose field ID matches the key of the entry has a + * value that equals the value of the entry. Values of entries are data-value + * dictionaries. + */ +@property (nonatomic, copy, nullable) NSDictionary *> * expectedResult; + +- (instancetype)initWithPath:(MTRCommandPath *)path + commandFields:(nullable NSDictionary *)commandFields + expectedResult:(nullable NSDictionary *> *)expectedResult; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.mm b/src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.mm new file mode 100644 index 00000000000000..558b7a1cc8cdcc --- /dev/null +++ b/src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.mm @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2025 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 + * + * http://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. + */ + +#import "MTRDeviceDataValidation.h" +#import "MTRLogging_Internal.h" +#import + +@implementation MTRCommandWithExpectedResult +- (instancetype)initWithPath:(MTRCommandPath *)path + commandFields:(nullable NSDictionary *)commandFields + expectedResult:(nullable NSDictionary *> *)expectedResult +{ + if (self = [super init]) { + self.path = path; + self.commandFields = commandFields; + self.expectedResult = expectedResult; + } + + return self; +} + +- (id)copyWithZone:(NSZone *)zone +{ + return [[MTRCommandWithExpectedResult alloc] initWithPath:self.path commandFields:self.commandFields expectedResult:self.expectedResult]; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"<%@: %p, path: %@, fields: %@, expectedResult: %@", NSStringFromClass(self.class), self, self.path, self.commandFields, self.expectedResult]; +} + +#pragma mark - MTRCommandWithExpectedResult NSSecureCoding implementation + +static NSString * const sPathKey = @"pathKey"; +static NSString * const sFieldsKey = @"fieldsKey"; +static NSString * const sExpectedResultKey = @"expectedResultKey"; + ++ (BOOL)supportsSecureCoding +{ + return YES; +} + +- (nullable instancetype)initWithCoder:(NSCoder *)decoder +{ + self = [super init]; + if (self == nil) { + return nil; + } + + _path = [decoder decodeObjectOfClass:MTRCommandPath.class forKey:sPathKey]; + if (!_path || ![_path isKindOfClass:MTRCommandPath.class]) { + MTR_LOG_ERROR("MTRCommandWithExpectedResult decoded %@ for endpoint, not MTRCommandPath.", _path); + return nil; + } + + _commandFields = [decoder decodeObjectOfClass:NSDictionary.class forKey:sFieldsKey]; + if (_commandFields) { + if (![_commandFields isKindOfClass:NSDictionary.class]) { + MTR_LOG_ERROR("MTRCommandWithExpectedResult decoded %@ for commandFields, not NSDictionary.", _commandFields); + return nil; + } + + if (!MTRDataValueDictionaryIsWellFormed(_commandFields) || ![MTRStructureValueType isEqual:_commandFields[MTRTypeKey]]) { + MTR_LOG_ERROR("MTRCommandWithExpectedResult decoded %@ for commandFields, not a structure-typed data-value dictionary.", _commandFields); + return nil; + } + } + + _expectedResult = [decoder decodeObjectOfClass:NSDictionary.class forKey:sExpectedResultKey]; + if (_expectedResult) { + if (![_expectedResult isKindOfClass:NSDictionary.class]) { + MTR_LOG_ERROR("MTRCommandWithExpectedResult decoded %@ for expectedResult, not NSDictionary.", _expectedResult); + return nil; + } + + for (id key in _expectedResult) { + if (![key isKindOfClass:NSNumber.class]) { + MTR_LOG_ERROR("MTRCommandWithExpectedResult decoded key %@ in expectedResult", key); + return nil; + } + + if (![_expectedResult[key] isKindOfClass:NSDictionary.class] || !MTRDataValueDictionaryIsWellFormed(_expectedResult[key])) { + MTR_LOG_ERROR("MTRCommandWithExpectedResult decoded value %@ for key %@ in expectedResult", _expectedResult[key], key); + return nil; + } + } + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + // In theory path is not nullable, but we don't really enforce that in init. + if (self.path) { + [coder encodeObject:self.path forKey:sPathKey]; + } + if (self.commandFields) { + [coder encodeObject:self.commandFields forKey:sFieldsKey]; + } + if (self.expectedResult) { + [coder encodeObject:self.expectedResult forKey:sExpectedResultKey]; + } +} + +@end diff --git a/src/darwin/Framework/CHIP/MTRDevice.h b/src/darwin/Framework/CHIP/MTRDevice.h index 1f5e9c9a075981..b0c180a3cb73f8 100644 --- a/src/darwin/Framework/CHIP/MTRDevice.h +++ b/src/darwin/Framework/CHIP/MTRDevice.h @@ -1,6 +1,6 @@ /** * - * Copyright (c) 2022-2023 Project CHIP Authors + * Copyright (c) 2022-2025 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. @@ -19,6 +19,7 @@ #import #import #import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -294,6 +295,31 @@ MTR_AVAILABLE(ios(16.1), macos(13.0), watchos(9.1), tvos(16.1)) completion:(MTRDeviceResponseHandler)completion MTR_AVAILABLE(ios(16.4), macos(13.3), watchos(9.4), tvos(16.4)); +/** + * Invoke one or more groups of commands. + * + * For any given group, if any command in any preceding group failed, the group + * will be skipped. If all commands in all preceding groups succeeded, the + * commands within the group will be invoked, with no ordering guarantees within + * that group. + * + * Results from all commands that were invoked will be passed to the provided + * completion as an array of response-value dictionaries. Each of these will + * have the command path of the command (see MTRCommandPathKey) and one of three + * things: + * + * 1) No other fields, indicating that the command invoke returned a succcess + * status. + * 2) A field for MTRErrorKey, indicating that the invoke returned a failure + * status (which is the value of the field). + * 3) A field for MTRDataKey, indicating that the invoke returned a data + * response. In this case the data-value representing the response will be + * the value of this field. + */ +- (void)invokeCommands:(NSArray *> *)commands + queue:(dispatch_queue_t)queue + completion:(void (^)(NSArray *> *))completion MTR_AVAILABLE(ios(18.4), macos(15.4), watchos(11.4), tvos(18.4)); + /** * Open a commissioning window on the device. * diff --git a/src/darwin/Framework/CHIP/MTRDevice.mm b/src/darwin/Framework/CHIP/MTRDevice.mm index 4bf71496b0cf1f..5251ca8e9dffa5 100644 --- a/src/darwin/Framework/CHIP/MTRDevice.mm +++ b/src/darwin/Framework/CHIP/MTRDevice.mm @@ -523,6 +523,14 @@ - (void)_invokeKnownCommandWithEndpointID:(NSNumber *)endpointID completion:responseHandler]; } +- (void)invokeCommands:(NSArray *> *)commands queue:(dispatch_queue_t)queue completion:(void (^)(NSArray *))completion +{ + MTR_ABSTRACT_METHOD(); + dispatch_async(queue, ^{ + completion(@[]); + }); +} + - (void)openCommissioningWindowWithSetupPasscode:(NSNumber *)setupPasscode discriminator:(NSNumber *)discriminator duration:(NSNumber *)duration diff --git a/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm b/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm index cb92e52a1dac9f..813a8a02d66631 100644 --- a/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm +++ b/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm @@ -1,6 +1,6 @@ /** * - * Copyright (c) 2022-2023 Project CHIP Authors + * Copyright (c) 2022-2025 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. @@ -3306,6 +3306,129 @@ - (void)_invokeCommandWithEndpointID:(NSNumber *)endpointID commandFields]; } +- (BOOL)_invokeResponse:(MTRDeviceResponseValueDictionary)response matchesExpectedResult:(NSDictionary *)expectedResult +{ + if (response[MTRDataKey] == nil) { + MTR_LOG_ERROR("%@ invokeCommands expects a data response for %@ but got no data", self, response[MTRCommandPathKey]); + return NO; + } + + MTRDeviceDataValueDictionary data = response[MTRDataKey]; + if (![MTRStructureValueType isEqual:data[MTRTypeKey]]) { + MTR_LOG_ERROR("%@ invokeCommands data value %@ for command response for %@ is not a structure", self, data, response[MTRCommandPathKey]); + return NO; + } + + NSArray *> * fields = data[MTRValueKey]; + + for (NSNumber * fieldID in expectedResult) { + // Check that this field is present in the response. + MTRDeviceDataValueDictionary _Nullable fieldValue = nil; + for (NSDictionary * field in fields) { + if ([fieldID isEqual:field[MTRContextTagKey]]) { + fieldValue = field[MTRDataKey]; + break; + } + } + + if (fieldValue == nil) { + MTR_LOG_ERROR("%@ invokeCommands response for %@ does not have a field with ID %@", self, response[MTRCommandPathKey], fieldID); + return NO; + } + + auto * expected = expectedResult[fieldID]; + if (![expected isEqual:fieldValue]) { + MTR_LOG_ERROR("%@ invokeCommands response for %@ field %@ got %@ but expected %@", self, response[MTRCommandPathKey], fieldID, fieldValue, expected); + return NO; + } + } + + return YES; +} + +- (void)invokeCommands:(NSArray *> *)commands + queue:(dispatch_queue_t)queue + completion:(void (^)(NSArray *))completion +{ + // We will generally do our work on self.queue, and just dispatch to the provided queue when + // calling the provided completion. + auto nextCompletion = ^(BOOL allSucceededSoFar, NSArray * responses) { + dispatch_async(queue, ^{ + completion(responses); + }); + }; + + // We want to invoke the command groups in order, stopping after failures as needed. Build up a + // linked list of groups via chaining the completions, with calls out to the original + // completion instead of going to the next list item when we want to stop. + for (NSArray * commandGroup in [commands reverseObjectEnumerator]) { + // We want to invoke all the commands in the group in order, propagating along the list of + // current responses. Build up that linked list of command invokes via chaining the completions. + for (MTRCommandWithExpectedResult * command in [commandGroup reverseObjectEnumerator]) { + auto commandInvokeBlock = ^(BOOL allSucceededSoFar, NSArray * previousResponses) { + [self invokeCommandWithEndpointID:command.path.endpoint + clusterID:command.path.cluster + commandID:command.path.command + commandFields:command.commandFields + expectedValues:nil + expectedValueInterval:nil + queue:self.queue + completion:^(NSArray *> * responses, NSError * error) { + if (error != nil) { + nextCompletion(NO, [previousResponses arrayByAddingObject:@ { + MTRCommandPathKey : command.path, + MTRErrorKey : error, + }]); + return; + } + + if (responses.count != 1) { + // Very much unexpected for invoking a single command. + MTR_LOG_ERROR("%@ invokeCommands unexpectedly got multiple responses for %@", self, command.path); + nextCompletion(NO, [previousResponses arrayByAddingObject:@ { + MTRCommandPathKey : command.path, + MTRErrorKey : [MTRError errorForCHIPErrorCode:CHIP_ERROR_INTERNAL], + }]); + return; + } + + BOOL nextAllSucceeded = allSucceededSoFar; + MTRDeviceResponseValueDictionary response = responses[0]; + if (command.expectedResult != nil && ![self _invokeResponse:response matchesExpectedResult:command.expectedResult]) { + nextAllSucceeded = NO; + } + + nextCompletion(nextAllSucceeded, [previousResponses arrayByAddingObject:response]); + }]; + }; + + nextCompletion = commandInvokeBlock; + } + + auto commandGroupInvokeBlock = ^(BOOL allSucceededSoFar, NSArray * previousResponses) { + // TODO: Maybe we ignore allSucceededSoFar if arguments say so? + + if (allSucceededSoFar == NO) { + // Don't start a new command group if something failed in the + // previous one. Note that we might be running on self.queue here, so make sure we + // dispatch to the correct queue. + MTR_LOG_ERROR("%@ failed a preceding command, not invoking command group %@ or later ones", self, commandGroup); + dispatch_async(queue, ^{ + completion(previousResponses); + }); + return; + } + + nextCompletion(allSucceededSoFar, previousResponses); + }; + + nextCompletion = commandGroupInvokeBlock; + } + + // Kick things off with a "everything succeeded so far and we have no responses yet. + nextCompletion(YES, @[]); +} + - (void)openCommissioningWindowWithSetupPasscode:(NSNumber *)setupPasscode discriminator:(NSNumber *)discriminator duration:(NSNumber *)duration diff --git a/src/darwin/Framework/CHIP/MTRDevice_XPC.mm b/src/darwin/Framework/CHIP/MTRDevice_XPC.mm index bc5fa52b327236..a0425a1593f8d4 100644 --- a/src/darwin/Framework/CHIP/MTRDevice_XPC.mm +++ b/src/darwin/Framework/CHIP/MTRDevice_XPC.mm @@ -408,7 +408,9 @@ - (void)_invokeCommandWithEndpointID:(NSNumber *)endpointID @try { [[xpcConnection remoteObjectProxyWithErrorHandler:^(NSError * _Nonnull error) { MTR_LOG_ERROR("Invoke error: %@", error); - completion(nil, [NSError errorWithDomain:MTRErrorDomain code:MTRErrorCodeGeneralError userInfo:nil]); + dispatch_async(queue, ^{ + completion(nil, [NSError errorWithDomain:MTRErrorDomain code:MTRErrorCodeGeneralError userInfo:nil]); + }); }] deviceController:[[self deviceController] uniqueIdentifier] nodeID:[self nodeID] invokeCommandWithEndpointID:endpointID @@ -420,36 +422,76 @@ - (void)_invokeCommandWithEndpointID:(NSNumber *)endpointID timedInvokeTimeout:timeout serverSideProcessingTimeout:serverSideProcessingTimeout completion:^(NSArray *> * _Nullable values, NSError * _Nullable error) { - if (values == nil && error == nil) { - MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) without values or error", self, endpointID, clusterID, commandID); - completion(nil, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INVALID_ARGUMENT]); - return; - } - - if (error != nil && !MTR_SAFE_CAST(error, NSError)) { - MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) that has invalid error object: %@", self, endpointID, clusterID, commandID, error); - completion(nil, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INVALID_ARGUMENT]); - return; - } - - if (values != nil && !MTRInvokeResponseIsWellFormed(values)) { - MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) that has invalid data: %@", self, clusterID, commandID, values, values); - completion(nil, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INVALID_ARGUMENT]); - return; - } - - if (values != nil && error != nil) { - MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) with both values and error: %@, %@", self, endpointID, clusterID, commandID, values, error); - // Just propagate through the error. - completion(nil, error); - return; - } - - completion(values, error); + dispatch_async(queue, ^{ + if (values == nil && error == nil) { + MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) without values or error", self, endpointID, clusterID, commandID); + completion(nil, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INVALID_ARGUMENT]); + return; + } + + if (error != nil && !MTR_SAFE_CAST(error, NSError)) { + MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) that has invalid error object: %@", self, endpointID, clusterID, commandID, error); + completion(nil, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INVALID_ARGUMENT]); + return; + } + + if (values != nil && !MTRInvokeResponseIsWellFormed(values)) { + MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) that has invalid data: %@", self, clusterID, commandID, values, values); + completion(nil, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INVALID_ARGUMENT]); + return; + } + + if (values != nil && error != nil) { + MTR_LOG_ERROR("%@ got invoke response for (%@, %@, %@) with both values and error: %@, %@", self, endpointID, clusterID, commandID, values, error); + // Just propagate through the error. + completion(nil, error); + return; + } + + completion(values, error); + }); }]; } @catch (NSException * exception) { MTR_LOG_ERROR("Exception sending XPC message: %@", exception); - completion(nil, [NSError errorWithDomain:MTRErrorDomain code:MTRErrorCodeGeneralError userInfo:nil]); + dispatch_async(queue, ^{ + completion(nil, [NSError errorWithDomain:MTRErrorDomain code:MTRErrorCodeGeneralError userInfo:nil]); + }); + } +} + +- (void)invokeCommands:(NSArray *> *)commands + queue:(dispatch_queue_t)queue + completion:(void (^)(NSArray *))completion +{ + NSXPCConnection * xpcConnection = [(MTRDeviceController_XPC *) [self deviceController] xpcConnection]; + + @try { + [[xpcConnection remoteObjectProxyWithErrorHandler:^(NSError * _Nonnull error) { + MTR_LOG_ERROR("Error: %@", error); + dispatch_async(queue, ^{ + // Should the error cases here synthesize errors for everything in the first group? + completion(@[]); + }); + }] deviceController:[[self deviceController] uniqueIdentifier] + nodeID:[self nodeID] + invokeCommands:commands + completion:^(NSArray * responses) { + dispatch_async(queue, ^{ + if (!MTRInvokeResponseIsWellFormed(responses)) { + MTR_LOG_ERROR("%@ got non-well-formed response for invokeCommands:queue:completion: %@", self, responses); + completion(@[]); + return; + } + + completion(responses); + }); + }]; + } @catch (NSException * exception) { + MTR_LOG_ERROR("Exception sending XPC message: %@", exception); + // Should the error cases here synthesize errors for everything in the first group? + dispatch_async(queue, ^{ + completion(@[]); + }); } } diff --git a/src/darwin/Framework/CHIP/Matter.h b/src/darwin/Framework/CHIP/Matter.h index bf643f654bf763..2e5fceac068392 100644 --- a/src/darwin/Framework/CHIP/Matter.h +++ b/src/darwin/Framework/CHIP/Matter.h @@ -34,6 +34,7 @@ #import #import #import +#import #import #import #import diff --git a/src/darwin/Framework/CHIP/XPC Protocol/MTRXPCServerProtocol.h b/src/darwin/Framework/CHIP/XPC Protocol/MTRXPCServerProtocol.h index 3e80bc4e67770f..5839ea4ecd047f 100644 --- a/src/darwin/Framework/CHIP/XPC Protocol/MTRXPCServerProtocol.h +++ b/src/darwin/Framework/CHIP/XPC Protocol/MTRXPCServerProtocol.h @@ -52,6 +52,8 @@ MTR_AVAILABLE(ios(18.2), macos(15.2), watchos(11.2), tvos(18.2)) */ - (oneway void)deviceController:(NSUUID *)controller nodeID:(NSNumber *)nodeID downloadLogOfType:(MTRDiagnosticLogType)type timeout:(NSTimeInterval)timeout completion:(void (^)(NSURL * _Nullable url, NSError * _Nullable error))completion; +- (oneway void)deviceController:(NSUUID *)controller nodeID:(NSNumber *)nodeID invokeCommands:(NSArray *> *)commands completion:(void (^)(NSArray *> *))completion MTR_AVAILABLE(ios(18.4), macos(15.4), watchos(11.4), tvos(18.4)); + @end MTR_AVAILABLE(ios(18.3), macos(15.3), watchos(11.3), tvos(18.3)) diff --git a/src/darwin/Framework/CHIPTests/MTRDeviceTests.m b/src/darwin/Framework/CHIPTests/MTRDeviceTests.m index 729e7a318a9213..03b59ce64bdf2e 100644 --- a/src/darwin/Framework/CHIPTests/MTRDeviceTests.m +++ b/src/darwin/Framework/CHIPTests/MTRDeviceTests.m @@ -5724,6 +5724,263 @@ - (void)test044_InvokeResponseWellFormedness } } +- (void)test045_MTRDeviceInvokeGroups +{ + __auto_type * device = [MTRDevice deviceWithNodeID:kDeviceId deviceController:sController]; + dispatch_queue_t queue = dispatch_get_main_queue(); + + // First test: Do an invoke with a single group with three commands, ensure + // that we get reasonable responses for them all. + + __auto_type * onPath = [MTRCommandPath commandPathWithEndpointID:@(1) + clusterID:@(MTRClusterIDTypeOnOffID) + commandID:@(MTRCommandIDTypeClusterOnOffCommandOnID)]; + __auto_type * togglePath = [MTRCommandPath commandPathWithEndpointID:@(1) + clusterID:@(MTRClusterIDTypeOnOffID) + commandID:@(MTRCommandIDTypeClusterOnOffCommandToggleID)]; + __auto_type * offPath = [MTRCommandPath commandPathWithEndpointID:@(1) + clusterID:@(MTRClusterIDTypeOnOffID) + commandID:@(MTRCommandIDTypeClusterOnOffCommandOffID)]; + + __auto_type * onCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:onPath commandFields:nil expectedResult:nil]; + __auto_type * toggleCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:togglePath commandFields:nil expectedResult:nil]; + __auto_type * offCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:offPath commandFields:nil expectedResult:nil]; + + XCTestExpectation * simpleInvokeDone = [self expectationWithDescription:@"Invoke of a single 3-command group done"]; + [device invokeCommands:@[ @[ onCommand, toggleCommand, offCommand ] ] queue:queue completion:^(NSArray *> * values) { + // Successful invoke is represented as a value with the relevant + // command path and neither data nor error. + __auto_type expectedValues = @[ + @ { MTRCommandPathKey : onPath }, + @ { MTRCommandPathKey : togglePath }, + @ { MTRCommandPathKey : offPath }, + ]; + XCTAssertEqualObjects(values, expectedValues); + [simpleInvokeDone fulfill]; + }]; + + // 3 commands, so use triple the timeout. + [self waitForExpectations:@[ simpleInvokeDone ] timeout:(3 * kTimeoutInSeconds)]; + + // Second test: Do an invoke with three groups. First command in the first + // group fails, but we should still run all commands in that group. We + // should not run any commands in any other groups. + __auto_type * failingTogglePath = [MTRCommandPath commandPathWithEndpointID:@(1000) // No such endpoint + clusterID:@(MTRClusterIDTypeOnOffID) + commandID:@(MTRCommandIDTypeClusterOnOffCommandToggleID)]; + __auto_type * failingToggleCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:failingTogglePath commandFields:nil expectedResult:nil]; + + XCTestExpectation * failingWithStatusInvokeDone = [self expectationWithDescription:@"Invoke of commands where one fails with a status done"]; + [device invokeCommands:@[ @[ failingToggleCommand, offCommand ], @[ onCommand, toggleCommand ], @[ failingToggleCommand ] ] queue:queue completion:^(NSArray *> * values) { + // We should not have anything for groups after the first one + XCTAssertEqual(values.count, 2); + NSDictionary * firstValue = values[0]; + XCTAssertEqualObjects(firstValue[MTRCommandPathKey], failingTogglePath); + XCTAssertNil(firstValue[MTRDataKey]); + XCTAssertNotNil(firstValue[MTRErrorKey]); + XCTAssertTrue([MTRErrorTestUtils error:firstValue[MTRErrorKey] isInteractionModelError:MTRInteractionErrorCodeUnsupportedEndpoint]); + + XCTAssertEqualObjects(values[1], @ { MTRCommandPathKey : offPath }); + + [failingWithStatusInvokeDone fulfill]; + }]; + + // 2 commands actually run, so use double the timeout. + [self waitForExpectations:@[ failingWithStatusInvokeDone ] timeout:(2 * kTimeoutInSeconds)]; + + // Third test: Do an invoke with three groups. One of the commands in the + // first group expects a data response but gets a status, which should be + // treated as a failure. + __auto_type * onCommandExpectingData = [[MTRCommandWithExpectedResult alloc] initWithPath:onPath commandFields:nil expectedResult:@{ + @(0) : @ { + MTRTypeKey : MTRUnsignedIntegerValueType, + MTRValueKey : @(0), + } + }]; + + XCTestExpectation * failingWithMissingDataInvokeDone = [self expectationWithDescription:@"Invoke of commands where one fails with missing data done"]; + [device invokeCommands:@[ @[ toggleCommand, onCommandExpectingData, offCommand ], @[ onCommand, toggleCommand ], @[ failingToggleCommand ] ] queue:queue completion:^(NSArray *> * values) { + // We should not have anything for groups after the first one + __auto_type * expectedValues = @[ + @ { MTRCommandPathKey : togglePath }, + @ { MTRCommandPathKey : onPath }, + @ { MTRCommandPathKey : offPath }, + ]; + XCTAssertEqualObjects(values, expectedValues); + + [failingWithMissingDataInvokeDone fulfill]; + }]; + + // 3 commands actually run, so use triple the timeout. + [self waitForExpectations:@[ failingWithMissingDataInvokeDone ] timeout:(3 * kTimeoutInSeconds)]; + + // Fourth test: do an invoke with two groups. One of the commands in the + // first group expects to not get a falure status and gets data, which + // should be treated as success. + __auto_type * updateFabricLabelPath = [MTRCommandPath commandPathWithEndpointID:@(0) + clusterID:@(MTRClusterIDTypeOperationalCredentialsID) + commandID:@(MTRCommandIDTypeClusterOperationalCredentialsCommandUpdateFabricLabelID)]; + __auto_type * nocResponsePath = [MTRCommandPath commandPathWithEndpointID:@(0) + clusterID:@(MTRClusterIDTypeOperationalCredentialsID) + commandID:@(MTRCommandIDTypeClusterOperationalCredentialsCommandNOCResponseID)]; + __auto_type * updateFabricLabelFields = @{ + MTRTypeKey : MTRStructureValueType, + MTRValueKey : @[ + @{ + MTRContextTagKey : @(0), + MTRDataKey : @ { + MTRTypeKey : MTRUTF8StringValueType, + MTRValueKey : @"newlabel", + }, + }, + ] + }; + __auto_type * updateFabricLabelNotExpectingFailureCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:updateFabricLabelPath commandFields:updateFabricLabelFields expectedResult:nil]; + + XCTestExpectation * updateFabricLabelNotExpectingFailureExpectation = [self expectationWithDescription:@"Invoke of commands where no failure is expected and data response is received done"]; + [device invokeCommands:@[ @[ updateFabricLabelNotExpectingFailureCommand, onCommand ], @[ offCommand ] ] queue:queue completion:^(NSArray *> * values) { + XCTAssertEqual(values.count, 3); + + NSDictionary * updateFabricLabelResponse = values[0]; + XCTAssertEqualObjects(updateFabricLabelResponse[MTRCommandPathKey], nocResponsePath); + NSDictionary * responseData = updateFabricLabelResponse[MTRDataKey]; + XCTAssertEqualObjects(responseData[MTRTypeKey], MTRStructureValueType); + NSArray *> * responseFields = responseData[MTRValueKey]; + XCTAssertTrue(responseFields.count > 0); + + for (NSDictionary * field in responseFields) { + if ([@(0) isEqual:field[MTRContextTagKey]]) { + // Check that this in fact succeeded. + NSDictionary * fieldValue = field[MTRDataKey]; + XCTAssertEqualObjects(fieldValue[MTRTypeKey], MTRUnsignedIntegerValueType); + XCTAssertEqualObjects(fieldValue[MTRValueKey], @(MTROperationalCredentialsNodeOperationalCertStatusOK)); + } + } + + XCTAssertEqualObjects(values[1], @ { MTRCommandPathKey : onPath }); + XCTAssertEqualObjects(values[2], @ { MTRCommandPathKey : offPath }); + + [updateFabricLabelNotExpectingFailureExpectation fulfill]; + }]; + + // 3 commands actually run, so use triple the timeout. + [self waitForExpectations:@[ updateFabricLabelNotExpectingFailureExpectation ] timeout:(3 * kTimeoutInSeconds)]; + + // Fifth test: do an invoke with two groups. One of the commands in the + // first group expects to get a data response and gets it, which should be + // treated as success. + __auto_type * updateFabricLabelExpectingOKCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:updateFabricLabelPath commandFields:updateFabricLabelFields expectedResult:@{ + @(0) : @ { + MTRTypeKey : MTRUnsignedIntegerValueType, + MTRValueKey : @(MTROperationalCredentialsNodeOperationalCertStatusOK), + }, + }]; + + XCTestExpectation * updateFabricLabelExpectingOKExpectation = [self expectationWithDescription:@"Invoke of commands where data response is expected and received done"]; + [device invokeCommands:@[ @[ updateFabricLabelExpectingOKCommand, onCommand ], @[ offCommand ] ] queue:queue completion:^(NSArray *> * values) { + XCTAssertEqual(values.count, 3); + + NSDictionary * updateFabricLabelResponse = values[0]; + XCTAssertEqualObjects(updateFabricLabelResponse[MTRCommandPathKey], nocResponsePath); + NSDictionary * responseData = updateFabricLabelResponse[MTRDataKey]; + XCTAssertEqualObjects(responseData[MTRTypeKey], MTRStructureValueType); + NSArray *> * responseFields = responseData[MTRValueKey]; + XCTAssertTrue(responseFields.count > 0); + + for (NSDictionary * field in responseFields) { + if ([@(0) isEqual:field[MTRContextTagKey]]) { + // Check that this in fact succeeded. + NSDictionary * fieldValue = field[MTRDataKey]; + XCTAssertEqualObjects(fieldValue[MTRTypeKey], MTRUnsignedIntegerValueType); + XCTAssertEqualObjects(fieldValue[MTRValueKey], @(MTROperationalCredentialsNodeOperationalCertStatusOK)); + } + } + + XCTAssertEqualObjects(values[1], @ { MTRCommandPathKey : onPath }); + XCTAssertEqualObjects(values[2], @ { MTRCommandPathKey : offPath }); + + [updateFabricLabelExpectingOKExpectation fulfill]; + }]; + + // 3 commands actually run, so use triple the timeout. + [self waitForExpectations:@[ updateFabricLabelExpectingOKExpectation ] timeout:(3 * kTimeoutInSeconds)]; + + // Sixth test: do an invoke with two groups. One of the commands in the + // first group expects to get a data response with a field that it does not get, which should be + // treated as failure. + __auto_type * updateFabricLabelExpectingNonexistentFieldCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:updateFabricLabelPath commandFields:updateFabricLabelFields expectedResult:@{ + @(20) : @ { + MTRTypeKey : MTRUnsignedIntegerValueType, + MTRValueKey : @(MTROperationalCredentialsNodeOperationalCertStatusOK), + }, + }]; + + XCTestExpectation * updateFabricLabelExpectingNonexistentFieldExpectation = [self expectationWithDescription:@"Invoke of commands where data response is expected but the received one is missing a field done"]; + [device invokeCommands:@[ @[ updateFabricLabelExpectingNonexistentFieldCommand, onCommand ], @[ offCommand ] ] queue:queue completion:^(NSArray *> * values) { + XCTAssertEqual(values.count, 2); + + NSDictionary * updateFabricLabelResponse = values[0]; + XCTAssertEqualObjects(updateFabricLabelResponse[MTRCommandPathKey], nocResponsePath); + NSDictionary * responseData = updateFabricLabelResponse[MTRDataKey]; + XCTAssertEqualObjects(responseData[MTRTypeKey], MTRStructureValueType); + NSArray *> * responseFields = responseData[MTRValueKey]; + XCTAssertTrue(responseFields.count > 0); + + for (NSDictionary * field in responseFields) { + if ([@(0) isEqual:field[MTRContextTagKey]]) { + // Check that this in fact succeeded. + NSDictionary * fieldValue = field[MTRDataKey]; + XCTAssertEqualObjects(fieldValue[MTRTypeKey], MTRUnsignedIntegerValueType); + XCTAssertEqualObjects(fieldValue[MTRValueKey], @(MTROperationalCredentialsNodeOperationalCertStatusOK)); + } + } + + XCTAssertEqualObjects(values[1], @ { MTRCommandPathKey : onPath }); + + [updateFabricLabelExpectingNonexistentFieldExpectation fulfill]; + }]; + + // 2 commands actually run, so use double the timeout. + [self waitForExpectations:@[ updateFabricLabelExpectingNonexistentFieldExpectation ] timeout:(2 * kTimeoutInSeconds)]; + + // Seventh test: do an invoke with two groups. One of the commands in the // first group expects to get a data response with a field value that does + // not match what it gets, which should be treated as a failure. + __auto_type * updateFabricLabelExpectingWrongValueCommand = [[MTRCommandWithExpectedResult alloc] initWithPath:updateFabricLabelPath commandFields:updateFabricLabelFields expectedResult:@{ + @(0) : @ { + MTRTypeKey : MTRUnsignedIntegerValueType, + MTRValueKey : @(MTROperationalCredentialsNodeOperationalCertStatusFabricConflict), + }, + }]; + + XCTestExpectation * updateFabricLabelExpectingWrongValueExpectation = [self expectationWithDescription:@"Invoke of commands where data response is expected but with the wrong value done"]; + [device invokeCommands:@[ @[ updateFabricLabelExpectingWrongValueCommand, onCommand ], @[ offCommand ] ] queue:queue completion:^(NSArray *> * values) { + XCTAssertEqual(values.count, 2); + + NSDictionary * updateFabricLabelResponse = values[0]; + XCTAssertEqualObjects(updateFabricLabelResponse[MTRCommandPathKey], nocResponsePath); + NSDictionary * responseData = updateFabricLabelResponse[MTRDataKey]; + XCTAssertEqualObjects(responseData[MTRTypeKey], MTRStructureValueType); + NSArray *> * responseFields = responseData[MTRValueKey]; + XCTAssertTrue(responseFields.count > 0); + + for (NSDictionary * field in responseFields) { + if ([@(0) isEqual:field[MTRContextTagKey]]) { + // Check that this in fact succeeded. + NSDictionary * fieldValue = field[MTRDataKey]; + XCTAssertEqualObjects(fieldValue[MTRTypeKey], MTRUnsignedIntegerValueType); + XCTAssertEqualObjects(fieldValue[MTRValueKey], @(MTROperationalCredentialsNodeOperationalCertStatusOK)); + } + } + + XCTAssertEqualObjects(values[1], @ { MTRCommandPathKey : onPath }); + + [updateFabricLabelExpectingWrongValueExpectation fulfill]; + }]; + + // 2 commands actually run, so use double the timeout. + [self waitForExpectations:@[ updateFabricLabelExpectingWrongValueExpectation ] timeout:(2 * kTimeoutInSeconds)]; +} + @end @interface MTRDeviceEncoderTests : XCTestCase diff --git a/src/darwin/Framework/Matter.xcodeproj/project.pbxproj b/src/darwin/Framework/Matter.xcodeproj/project.pbxproj index 61f5bdf3d3c313..be5c2c7f3c6dd0 100644 --- a/src/darwin/Framework/Matter.xcodeproj/project.pbxproj +++ b/src/darwin/Framework/Matter.xcodeproj/project.pbxproj @@ -159,6 +159,8 @@ 512431282BA0C8BF000BC136 /* SetMRPParametersCommand.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5124311C2BA0C09A000BC136 /* SetMRPParametersCommand.mm */; }; 512431292BA0C8BF000BC136 /* ResetMRPParametersCommand.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5124311A2BA0C09A000BC136 /* ResetMRPParametersCommand.mm */; }; 5129BCFD26A9EE3300122DDF /* MTRError.h in Headers */ = {isa = PBXBuildFile; fileRef = 5129BCFC26A9EE3300122DDF /* MTRError.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 512E8E7A2D52F7B6009407E3 /* MTRCommandWithExpectedResult.mm in Sources */ = {isa = PBXBuildFile; fileRef = 512E8E792D52F7B6009407E3 /* MTRCommandWithExpectedResult.mm */; }; + 512E8E7B2D52F7B6009407E3 /* MTRCommandWithExpectedResult.h in Headers */ = {isa = PBXBuildFile; fileRef = 512E8E782D52F7B6009407E3 /* MTRCommandWithExpectedResult.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5131BF662BE2E1B000D5D6BC /* MTRTestCase.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5131BF642BE2E1B000D5D6BC /* MTRTestCase.mm */; }; 51339B1F2A0DA64D00C798C1 /* MTRCertificateValidityTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 51339B1E2A0DA64D00C798C1 /* MTRCertificateValidityTests.m */; }; 5136661328067D550025EDAE /* MTRDeviceController_Internal.h in Headers */ = {isa = PBXBuildFile; fileRef = 5136660F28067D540025EDAE /* MTRDeviceController_Internal.h */; }; @@ -668,6 +670,8 @@ 5124311B2BA0C09A000BC136 /* SetMRPParametersCommand.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SetMRPParametersCommand.h; sourceTree = ""; }; 5124311C2BA0C09A000BC136 /* SetMRPParametersCommand.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = SetMRPParametersCommand.mm; sourceTree = ""; }; 5129BCFC26A9EE3300122DDF /* MTRError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRError.h; sourceTree = ""; }; + 512E8E782D52F7B6009407E3 /* MTRCommandWithExpectedResult.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRCommandWithExpectedResult.h; sourceTree = ""; }; + 512E8E792D52F7B6009407E3 /* MTRCommandWithExpectedResult.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MTRCommandWithExpectedResult.mm; sourceTree = ""; }; 5131BF642BE2E1B000D5D6BC /* MTRTestCase.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = MTRTestCase.mm; sourceTree = ""; }; 5131BF652BE2E1B000D5D6BC /* MTRTestCase.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTRTestCase.h; sourceTree = ""; }; 51339B1E2A0DA64D00C798C1 /* MTRCertificateValidityTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTRCertificateValidityTests.m; sourceTree = ""; }; @@ -1502,6 +1506,8 @@ 5178E67F2AE098510069DF72 /* MTRCommandTimedCheck.h */, 51FE723E2ACDEF3E00437032 /* MTRCommandPayloadExtensions_Internal.h */, 5178E6802AE098520069DF72 /* MTRCommissionableBrowserResult_Internal.h */, + 512E8E782D52F7B6009407E3 /* MTRCommandWithExpectedResult.h */, + 512E8E792D52F7B6009407E3 /* MTRCommandWithExpectedResult.mm */, 3D010DCD2D408FA300CFFA02 /* MTRCommissioneeInfo.h */, 3D010DD12D4091C800CFFA02 /* MTRCommissioneeInfo_Internal.h */, 3D010DCE2D408FA300CFFA02 /* MTRCommissioneeInfo.mm */, @@ -1927,6 +1933,7 @@ AF1CB86E2874B03B00865A96 /* MTROTAProviderDelegate.h in Headers */, 51D0B1402B61B3A4006E3511 /* MTRServerCluster.h in Headers */, 754F3DF427FBB94B00E60580 /* MTREventTLVValueDecoder_Internal.h in Headers */, + 512E8E7B2D52F7B6009407E3 /* MTRCommandWithExpectedResult.h in Headers */, 3CF134AF289D90FF0017A19E /* MTROperationalCertificateIssuer.h in Headers */, 5178E6822AE098520069DF72 /* MTRCommissionableBrowserResult_Internal.h in Headers */, 516415FD2B6ACA8300D5CE11 /* MTRServerAccessControl.h in Headers */, @@ -2362,6 +2369,7 @@ 514C79F02B62ADDA00DD6D7B /* descriptor.cpp in Sources */, 5109E9B42CB8B5DF0006884B /* MTRDeviceType.mm in Sources */, 3D843757294AD25A0070D20A /* MTRCertificateInfo.mm in Sources */, + 512E8E7A2D52F7B6009407E3 /* MTRCommandWithExpectedResult.mm in Sources */, 3D9F2FCE2D112295003CA2BB /* MTRAttributeTLVValueDecoder_Internal.mm in Sources */, 5A7947E427C0129600434CF2 /* MTRDeviceController+XPC.mm in Sources */, 7592BD0B2CC6BCC300EB74A0 /* EmberAttributeDataBuffer.cpp in Sources */,