Skip to content

Commit

Permalink
Add a Matter.framework API for invoking multiple commands.
Browse files Browse the repository at this point in the history
The API allows grouping the commands, so that later groups don't get invoked if
anything in an earlier group fails.
  • Loading branch information
bzbarsky-apple committed Feb 5, 2025
1 parent e490944 commit a0c93ca
Show file tree
Hide file tree
Showing 10 changed files with 678 additions and 30 deletions.
62 changes: 62 additions & 0 deletions src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.h
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>
#import <Matter/MTRBaseDevice.h> // 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 <NSCopying, NSSecureCoding>

/**
* 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<NSString *, id> * 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<NSNumber *, NSDictionary<NSString *, id> *> * expectedResult;

- (instancetype)initWithPath:(MTRCommandPath *)path
commandFields:(nullable NSDictionary<NSString *, id> *)commandFields
expectedResult:(nullable NSDictionary<NSNumber *, NSDictionary<NSString *, id> *> *)expectedResult;

@end

NS_ASSUME_NONNULL_END
119 changes: 119 additions & 0 deletions src/darwin/Framework/CHIP/MTRCommandWithExpectedResult.mm
Original file line number Diff line number Diff line change
@@ -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 <Matter/Matter.h>

@implementation MTRCommandWithExpectedResult
- (instancetype)initWithPath:(MTRCommandPath *)path
commandFields:(nullable NSDictionary<NSString *, id> *)commandFields
expectedResult:(nullable NSDictionary<NSNumber *, NSDictionary<NSString *, id> *> *)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
28 changes: 27 additions & 1 deletion src/darwin/Framework/CHIP/MTRDevice.h
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,6 +19,7 @@
#import <Matter/MTRAttributeValueWaiter.h>
#import <Matter/MTRBaseClusters.h>
#import <Matter/MTRBaseDevice.h>
#import <Matter/MTRCommandWithExpectedResult.h>
#import <Matter/MTRDefines.h>

NS_ASSUME_NONNULL_BEGIN
Expand Down Expand Up @@ -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<NSArray<MTRCommandWithExpectedResult *> *> *)commands
queue:(dispatch_queue_t)queue
completion:(void (^)(NSArray<NSDictionary<NSString *, id> *> *))completion MTR_AVAILABLE(ios(18.4), macos(15.4), watchos(11.4), tvos(18.4));

/**
* Open a commissioning window on the device.
*
Expand Down
8 changes: 8 additions & 0 deletions src/darwin/Framework/CHIP/MTRDevice.mm
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,14 @@ - (void)_invokeKnownCommandWithEndpointID:(NSNumber *)endpointID
completion:responseHandler];
}

- (void)invokeCommands:(NSArray<NSArray<MTRCommandWithExpectedResult *> *> *)commands queue:(dispatch_queue_t)queue completion:(void (^)(NSArray<MTRDeviceResponseValueDictionary> *))completion
{
MTR_ABSTRACT_METHOD();
dispatch_async(queue, ^{
completion(@[]);
});
}

- (void)openCommissioningWindowWithSetupPasscode:(NSNumber *)setupPasscode
discriminator:(NSNumber *)discriminator
duration:(NSNumber *)duration
Expand Down
125 changes: 124 additions & 1 deletion src/darwin/Framework/CHIP/MTRDevice_Concrete.mm
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -3306,6 +3306,129 @@ - (void)_invokeCommandWithEndpointID:(NSNumber *)endpointID
commandFields];
}

- (BOOL)_invokeResponse:(MTRDeviceResponseValueDictionary)response matchesExpectedResult:(NSDictionary<NSNumber *, MTRDeviceDataValueDictionary> *)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<NSDictionary<NSString *, id> *> * fields = data[MTRValueKey];

for (NSNumber * fieldID in expectedResult) {
// Check that this field is present in the response.
MTRDeviceDataValueDictionary _Nullable fieldValue = nil;
for (NSDictionary<NSString *, id> * 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<NSArray<MTRCommandWithExpectedResult *> *> *)commands
queue:(dispatch_queue_t)queue
completion:(void (^)(NSArray<MTRDeviceResponseValueDictionary> *))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<MTRDeviceResponseValueDictionary> * 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<MTRCommandWithExpectedResult *> * 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<MTRDeviceResponseValueDictionary> * 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<NSDictionary<NSString *, id> *> * 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<MTRDeviceResponseValueDictionary> * 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
Expand Down
Loading

0 comments on commit a0c93ca

Please sign in to comment.