Add TestOnlySendBatchCommands to chip-repl (#31455)

* Add TestOnlySendBatchCommands to chip-repl

This allows chip-repl to send spec violating InvokeRequest for testing
purposes.

* Restyled by clang-format

* Restyled by autopep8

* Restyled by isort

* Fix CI

* Address PR comment

---------

Co-authored-by: Restyled.io <commits@restyled.io>
diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py
index 5f57c5e..20e2117 100644
--- a/src/controller/python/chip/ChipDeviceCtrl.py
+++ b/src/controller/python/chip/ChipDeviceCtrl.py
@@ -852,6 +852,34 @@
 
         return res
 
+    async def TestOnlySendBatchCommands(self, nodeid: int, commands: typing.List[ClusterCommand.InvokeRequestInfo],
+                                        timedRequestTimeoutMs: typing.Optional[int] = None,
+                                        interactionTimeoutMs: typing.Optional[int] = None, busyWaitMs: typing.Optional[int] = None,
+                                        suppressResponse: typing.Optional[bool] = None, remoteMaxPathsPerInvoke: typing.Optional[int] = None,
+                                        suppressTimedRequestMessage: bool = False, commandRefsOverride: typing.Optional[typing.List[int]] = None):
+        '''
+
+        Please see SendBatchCommands for description.
+        TestOnly overridable arguments:
+            remoteMaxPathsPerInvoke: Overrides the number of batch commands we think can be sent to remote node.
+            suppressTimedRequestMessage: When set to true, we suppress sending Timed Request Message.
+            commandRefsOverride: List of commandRefs to use for each command with the same index in `commands`.
+        '''
+        self.CheckIsActive()
+
+        eventLoop = asyncio.get_running_loop()
+        future = eventLoop.create_future()
+
+        device = self.GetConnectedDeviceSync(nodeid, timeoutMs=interactionTimeoutMs)
+
+        ClusterCommand.TestOnlySendBatchCommands(
+            future, eventLoop, device.deviceProxy, commands,
+            timedRequestTimeoutMs=timedRequestTimeoutMs,
+            interactionTimeoutMs=interactionTimeoutMs, busyWaitMs=busyWaitMs, suppressResponse=suppressResponse,
+            remoteMaxPathsPerInvoke=remoteMaxPathsPerInvoke, suppressTimedRequestMessage=suppressTimedRequestMessage,
+            commandRefsOverride=commandRefsOverride).raise_on_error()
+        return await future
+
     async def TestOnlySendCommandTimedRequestFlagWithNoTimedInvoke(self, nodeid: int, endpoint: int,
                                                                    payload: ClusterObjects.ClusterCommand, responseType=None):
         '''
@@ -927,7 +955,7 @@
                       - A value of `None` indicates success.
                       - If only a single command fails, for example with `UNSUPPORTED_COMMAND`, the corresponding index associated with the command will,
                         contain `interaction_model.Status.UnsupportedCommand`.
-                      - If a command is not responded to by server, command will contain `interaction_model.Status.Failure`
+                      - If a command is not responded to by server, command will contain `interaction_model.Status.NoCommandResponse`
         Raises:
             - InteractionModelError if error with sending of InvokeRequestMessage fails as a whole.
         '''
diff --git a/src/controller/python/chip/clusters/Command.py b/src/controller/python/chip/clusters/Command.py
index 6e25a76..4c556fd 100644
--- a/src/controller/python/chip/clusters/Command.py
+++ b/src/controller/python/chip/clusters/Command.py
@@ -27,7 +27,7 @@
 
 import chip.exceptions
 import chip.interaction_model
-from chip.interaction_model import PyInvokeRequestData
+from chip.interaction_model import PyInvokeRequestData, TestOnlyPyBatchCommandsOverrides
 from chip.native import PyChipError
 
 from .ClusterObjects import ClusterCommand
@@ -204,7 +204,9 @@
         )
 
     def _handleDone(self):
-        self._future.set_result(self._responses)
+        # Future might already be set with exception from `handleError`
+        if not self._future.done():
+            self._future.set_result(self._responses)
         ctypes.pythonapi.Py_DecRef(ctypes.py_object(self))
 
     def handleDone(self):
@@ -296,24 +298,7 @@
         ))
 
 
-def SendBatchCommands(future: Future, eventLoop, device, commands: List[InvokeRequestInfo],
-                      timedRequestTimeoutMs: Optional[int] = None, interactionTimeoutMs: Optional[int] = None, busyWaitMs: Optional[int] = None,
-                      suppressResponse: Optional[bool] = None) -> PyChipError:
-    ''' Send a cluster-object encapsulated command to a device and does the following:
-            - On receipt of a successful data response, returns the cluster-object equivalent through the provided future.
-            - None (on a successful response containing no data)
-            - Raises an exception if any errors are encountered.
-
-        If no response type is provided above, the type will be automatically deduced.
-
-        If a valid timedRequestTimeoutMs is provided, a timed interaction will be initiated instead.
-        If a valid interactionTimeoutMs is provided, the interaction will terminate with a CHIP_ERROR_TIMEOUT if a response
-        has not been received within that timeout. If it isn't provided, a sensible value will be automatically computed that
-        accounts for the underlying characteristics of both the transport and the responsiveness of the receiver.
-    '''
-    handle = chip.native.GetLibraryHandle()
-
-    responseTypes = []
+def _BuildPyInvokeRequestData(commands: List[InvokeRequestInfo], timedRequestTimeoutMs: Optional[int], responseTypes, suppressTimedRequestMessage: bool = False) -> List[PyInvokeRequestData]:
     numberOfCommands = len(commands)
     pyBatchCommandsDataArrayType = PyInvokeRequestData * numberOfCommands
     pyBatchCommandsData = pyBatchCommandsDataArrayType()
@@ -323,7 +308,8 @@
         if (responseType is not None) and (not issubclass(responseType, ClusterCommand)):
             raise ValueError("responseType must be a ClusterCommand or None")
         if clusterCommand.must_use_timed_invoke and timedRequestTimeoutMs is None or timedRequestTimeoutMs == 0:
-            raise chip.interaction_model.InteractionModelError(chip.interaction_model.Status.NeedsTimedInteraction)
+            if not suppressTimedRequestMessage:
+                raise chip.interaction_model.InteractionModelError(chip.interaction_model.Status.NeedsTimedInteraction)
 
         payloadTLV = clusterCommand.ToTLV()
 
@@ -335,6 +321,41 @@
 
         responseTypes.append(responseType)
 
+    return pyBatchCommandsData
+
+
+def SendBatchCommands(future: Future, eventLoop, device, commands: List[InvokeRequestInfo],
+                      timedRequestTimeoutMs: Optional[int] = None, interactionTimeoutMs: Optional[int] = None, busyWaitMs: Optional[int] = None,
+                      suppressResponse: Optional[bool] = None) -> PyChipError:
+    ''' Initiates an InvokeInteraction with the batch commands provided.
+
+    Arguments:
+        - timedRequestTimeoutMs: If a valid value is provided, a timed interaction will be initiated.
+        - interactionTimeoutMs: If a valid value is provided, the interaction will terminate with a
+          CHIP_ERROR_TIMEOUT if a response is not received within the specified timeout. If not provided,
+          a suitable value will be automatically computed based on transport characteristics and
+          receiver responsiveness.
+
+    Returns:
+        - PyChipError: Indicates the outcome of initiating the InvokeRequest. Upon success the caller
+          is expected to await on `future` to get result of the InvokeInteraction.
+
+    Results passed via the provided future:
+        - Successful InvokeInteraction with path-specific responses (including path-specific errors):
+            - A list of responses is returned in the same order as the `commands` argument.
+            - Possible response elements:
+                - `None`: Successful command execution without additional cluster data.
+                - Encapsulated cluster-object: Successful command with response data.
+                - interaction_model.Status.*: Command failure with IM Status.
+                - interaction_model.Status.NoCommandResponse: No response from the server for
+                  a specific command.
+        - Non-path-specific error: An `InteractionModelError` exception is raised through the future.
+    '''
+    handle = chip.native.GetLibraryHandle()
+
+    responseTypes = []
+    pyBatchCommandsData = _BuildPyInvokeRequestData(commands, timedRequestTimeoutMs, responseTypes)
+
     transaction = AsyncBatchCommandsTransaction(future, eventLoop, responseTypes)
     ctypes.pythonapi.Py_IncRef(ctypes.py_object(transaction))
 
@@ -345,7 +366,50 @@
             c_uint16(0 if interactionTimeoutMs is None else interactionTimeoutMs),
             c_uint16(0 if busyWaitMs is None else busyWaitMs),
             c_bool(False if suppressResponse is None else suppressResponse),
-            pyBatchCommandsData, c_size_t(numberOfCommands))
+            pyBatchCommandsData, c_size_t(len(pyBatchCommandsData)))
+    )
+
+
+def TestOnlySendBatchCommands(future: Future, eventLoop, device, commands: List[InvokeRequestInfo],
+                              timedRequestTimeoutMs: Optional[int] = None, interactionTimeoutMs: Optional[int] = None, busyWaitMs: Optional[int] = None,
+                              suppressResponse: Optional[bool] = None, remoteMaxPathsPerInvoke: Optional[int] = None,
+                              suppressTimedRequestMessage: bool = False, commandRefsOverride: Optional[List[int]] = None) -> PyChipError:
+    ''' ONLY TO BE USED FOR TEST: Send batch commands using various overrides.
+    '''
+    if suppressTimedRequestMessage and timedRequestTimeoutMs is not None:
+        raise ValueError("timedRequestTimeoutMs has non-None value while suppressTimedRequestMessage")
+
+    overrideCommandRefs = None
+    if commandRefsOverride is not None:
+        if len(commandRefsOverride) != len(commands):
+            raise ValueError("Mismatch in the number of elements provided in commandRefsOverride")
+        overrideCommandRefsType = c_uint16 * len(commandRefsOverride)
+        overrideCommandRefs = overrideCommandRefsType()
+
+    handle = chip.native.GetLibraryHandle()
+
+    responseTypes = []
+    pyBatchCommandsData = _BuildPyInvokeRequestData(commands, timedRequestTimeoutMs,
+                                                    responseTypes, suppressTimedRequestMessage=suppressTimedRequestMessage)
+
+    transaction = AsyncBatchCommandsTransaction(future, eventLoop, responseTypes)
+    ctypes.pythonapi.Py_IncRef(ctypes.py_object(transaction))
+
+    testOnlyOverrides = TestOnlyPyBatchCommandsOverrides()
+    testOnlyOverrides.suppressTimedRequestMessage = suppressTimedRequestMessage
+    testOnlyOverrides.overrideRemoteMaxPathsPerInvoke = 0 if remoteMaxPathsPerInvoke is None else c_uint16(remoteMaxPathsPerInvoke)
+    testOnlyOverrides.overrideCommandRefsList = overrideCommandRefs
+    testOnlyOverrides.overrideCommandRefsListLength = 0 if overrideCommandRefs is None else c_size_t(len(overrideCommandRefs))
+
+    return builtins.chipStack.Call(
+        lambda: handle.pychip_CommandSender_TestOnlySendBatchCommands(
+            py_object(transaction), device,
+            c_uint16(0 if timedRequestTimeoutMs is None else timedRequestTimeoutMs),
+            c_uint16(0 if interactionTimeoutMs is None else interactionTimeoutMs),
+            c_uint16(0 if busyWaitMs is None else busyWaitMs),
+            c_bool(False if suppressResponse is None else suppressResponse),
+            testOnlyOverrides,
+            pyBatchCommandsData, c_size_t(len(pyBatchCommandsData)))
     )
 
 
@@ -377,6 +441,8 @@
                    PyChipError, [py_object, c_void_p, c_uint16, c_uint32, c_uint32, c_char_p, c_size_t, c_uint16, c_bool])
         setter.Set('pychip_CommandSender_SendBatchCommands',
                    PyChipError, [py_object, c_void_p, c_uint16, c_uint16, c_uint16, c_bool, POINTER(PyInvokeRequestData), c_size_t])
+        setter.Set('pychip_CommandSender_TestOnlySendBatchCommands',
+                   PyChipError, [py_object, c_void_p, c_uint16, c_uint16, c_uint16, c_bool, TestOnlyPyBatchCommandsOverrides, POINTER(PyInvokeRequestData), c_size_t])
         setter.Set('pychip_CommandSender_TestOnlySendCommandTimedRequestNoTimedInvoke',
                    PyChipError, [py_object, c_void_p, c_uint32, c_uint32, c_char_p, c_size_t, c_uint16, c_bool])
         setter.Set('pychip_CommandSender_SendGroupCommand',
diff --git a/src/controller/python/chip/clusters/command.cpp b/src/controller/python/chip/clusters/command.cpp
index 7195cef..9d718aa 100644
--- a/src/controller/python/chip/clusters/command.cpp
+++ b/src/controller/python/chip/clusters/command.cpp
@@ -181,6 +181,132 @@
     bool mIsBatchedCommands;
 };
 
+PyChipError SendBatchCommandsInternal(void * appContext, DeviceProxy * device, uint16_t timedRequestTimeoutMs,
+                                      uint16_t interactionTimeoutMs, uint16_t busyWaitMs, bool suppressResponse,
+                                      python::TestOnlyPyBatchCommandsOverrides * testOnlyOverrides,
+                                      python::PyInvokeRequestData * batchCommandData, size_t length)
+{
+    CommandSender::ConfigParameters config;
+    CHIP_ERROR err = CHIP_NO_ERROR;
+
+    bool testOnlySuppressTimedRequestMessage = false;
+    uint16_t * testOnlyCommandRefsOverride   = nullptr;
+
+    VerifyOrReturnError(device->GetSecureSession().HasValue(), ToPyChipError(CHIP_ERROR_MISSING_SECURE_SESSION));
+
+    // Test only override validation checks and setup
+    if (testOnlyOverrides != nullptr)
+    {
+        if (testOnlyOverrides->suppressTimedRequestMessage)
+        {
+            VerifyOrReturnError(timedRequestTimeoutMs == 0, ToPyChipError(CHIP_ERROR_INVALID_ARGUMENT));
+            testOnlySuppressTimedRequestMessage = true;
+        }
+        if (testOnlyOverrides->overrideCommandRefsList != nullptr)
+        {
+            VerifyOrReturnError(length == testOnlyOverrides->overrideCommandRefsListLength,
+                                ToPyChipError(CHIP_ERROR_INVALID_ARGUMENT));
+            testOnlyCommandRefsOverride = testOnlyOverrides->overrideCommandRefsList;
+        }
+    }
+
+    if (testOnlyOverrides != nullptr && testOnlyOverrides->overrideRemoteMaxPathsPerInvoke)
+    {
+        config.SetRemoteMaxPathsPerInvoke(testOnlyOverrides->overrideRemoteMaxPathsPerInvoke);
+    }
+    else
+    {
+        auto remoteSessionParameters = device->GetSecureSession().Value()->GetRemoteSessionParameters();
+        config.SetRemoteMaxPathsPerInvoke(remoteSessionParameters.GetMaxPathsPerInvoke());
+    }
+
+    std::unique_ptr<CommandSenderCallback> callback =
+        std::make_unique<CommandSenderCallback>(appContext, /* isBatchedCommands =*/true);
+
+    bool isTimedRequest = timedRequestTimeoutMs != 0 || testOnlySuppressTimedRequestMessage;
+    std::unique_ptr<CommandSender> sender =
+        std::make_unique<CommandSender>(callback.get(), device->GetExchangeManager(), isTimedRequest, suppressResponse);
+
+    SuccessOrExit(err = sender->SetCommandSenderConfig(config));
+
+    for (size_t i = 0; i < length; i++)
+    {
+        chip::EndpointId endpointId = batchCommandData[i].commandPath.endpointId;
+        chip::ClusterId clusterId   = batchCommandData[i].commandPath.clusterId;
+        chip::CommandId commandId   = batchCommandData[i].commandPath.commandId;
+        void * tlv                  = batchCommandData[i].tlvData;
+        size_t tlvLength            = batchCommandData[i].tlvLength;
+
+        const uint8_t * tlvBuffer = reinterpret_cast<const uint8_t *>(tlv);
+
+        app::CommandPathParams cmdParams = { endpointId, /* group id */ 0, clusterId, commandId,
+                                             (app::CommandPathFlags::kEndpointIdValid) };
+
+        CommandSender::AdditionalCommandParameters additionalParams;
+
+        SuccessOrExit(err = sender->PrepareCommand(cmdParams, additionalParams));
+        if (testOnlyCommandRefsOverride != nullptr)
+        {
+            additionalParams.commandRef.SetValue(testOnlyCommandRefsOverride[i]);
+        }
+        {
+            auto writer = sender->GetCommandDataIBTLVWriter();
+            VerifyOrExit(writer != nullptr, err = CHIP_ERROR_INCORRECT_STATE);
+            TLV::TLVReader reader;
+            reader.Init(tlvBuffer, static_cast<uint32_t>(tlvLength));
+            reader.Next();
+            SuccessOrExit(err = writer->CopyContainer(TLV::ContextTag(CommandDataIB::Tag::kFields), reader));
+        }
+
+        SuccessOrExit(err = sender->FinishCommand(timedRequestTimeoutMs != 0 ? Optional<uint16_t>(timedRequestTimeoutMs)
+                                                                             : Optional<uint16_t>::Missing(),
+                                                  additionalParams));
+
+        // CommandSender provides us with the CommandReference for this associated command. In order to match responses
+        // we have to add CommandRef to index lookup.
+        VerifyOrExit(additionalParams.commandRef.HasValue(), err = CHIP_ERROR_INVALID_ARGUMENT);
+        if (testOnlyCommandRefsOverride != nullptr)
+        {
+            // Making sure the value we used to override CommandRef was actually used.
+            VerifyOrDie(additionalParams.commandRef.Value() == testOnlyCommandRefsOverride[i]);
+            // Ignoring the result of adding to index as the test might be trying to set duplicate CommandRefs.
+            callback->AddCommandRefToIndexLookup(additionalParams.commandRef.Value(), i);
+        }
+        else
+        {
+            SuccessOrExit(err = callback->AddCommandRefToIndexLookup(additionalParams.commandRef.Value(), i));
+        }
+    }
+
+    {
+        Optional<System::Clock::Timeout> interactionTimeout = interactionTimeoutMs != 0
+            ? MakeOptional(System::Clock::Milliseconds32(interactionTimeoutMs))
+            : Optional<System::Clock::Timeout>::Missing();
+        if (testOnlySuppressTimedRequestMessage)
+        {
+            SuccessOrExit(err = sender->TestOnlyCommandSenderTimedRequestFlagWithNoTimedInvoke(device->GetSecureSession().Value(),
+                                                                                               interactionTimeout));
+        }
+        else
+        {
+            SuccessOrExit(err = sender->SendCommandRequest(device->GetSecureSession().Value(), interactionTimeout));
+        }
+    }
+
+    sender.release();
+    callback.release();
+
+    // TODO(#30985): Reconsider the purpose of busyWait and if it can be broken out into it's
+    // own method/primitive.
+    if (busyWaitMs)
+    {
+        usleep(busyWaitMs * 1000);
+    }
+
+exit:
+    return ToPyChipError(err);
+}
+
 } // namespace python
 } // namespace chip
 
@@ -251,81 +377,23 @@
                                                    uint16_t interactionTimeoutMs, uint16_t busyWaitMs, bool suppressResponse,
                                                    python::PyInvokeRequestData * batchCommandData, size_t length)
 {
-    CHIP_ERROR err = CHIP_NO_ERROR;
+    python::TestOnlyPyBatchCommandsOverrides * testOnlyOverrides = nullptr;
+    return SendBatchCommandsInternal(appContext, device, timedRequestTimeoutMs, interactionTimeoutMs, busyWaitMs, suppressResponse,
+                                     testOnlyOverrides, batchCommandData, length);
+}
 
-    VerifyOrReturnError(device->GetSecureSession().HasValue(), ToPyChipError(CHIP_ERROR_MISSING_SECURE_SESSION));
-    auto remoteSessionParameters = device->GetSecureSession().Value()->GetRemoteSessionParameters();
-    CommandSender::ConfigParameters config;
-
-    // TODO(#30986): Need to create a separate pychip_CommandSender_TestOnlySendBatchCommands so that we perform
-    // operations that is very clear at callsite that violating certain aspects like setting this MaxPathsPerInvoke
-    // to a number other than what is reported by the remote node is allowed. Right now the only user of this
-    // function is cert test script. To implement pychip_CommandSender_TestOnlySendBatchCommands in a clean way
-    // we need to move away from the variadic arguments.
-    // config.SetRemoteMaxPathsPerInvoke(remoteSessionParameters.GetMaxPathsPerInvoke());
-    (void) remoteSessionParameters; // Still want to get remoteSessionParameters, just wont use it right now.
-    config.SetRemoteMaxPathsPerInvoke(std::numeric_limits<uint16_t>::max());
-
-    std::unique_ptr<CommandSenderCallback> callback =
-        std::make_unique<CommandSenderCallback>(appContext, /* isBatchedCommands =*/true);
-    std::unique_ptr<CommandSender> sender =
-        std::make_unique<CommandSender>(callback.get(), device->GetExchangeManager(),
-                                        /* is timed request */ timedRequestTimeoutMs != 0, suppressResponse);
-
-    SuccessOrExit(err = sender->SetCommandSenderConfig(config));
-
-    for (size_t i = 0; i < length; i++)
-    {
-        chip::EndpointId endpointId = batchCommandData[i].commandPath.endpointId;
-        chip::ClusterId clusterId   = batchCommandData[i].commandPath.clusterId;
-        chip::CommandId commandId   = batchCommandData[i].commandPath.commandId;
-        void * tlv                  = batchCommandData[i].tlvData;
-        size_t tlvLength            = batchCommandData[i].tlvLength;
-
-        const uint8_t * tlvBuffer = reinterpret_cast<const uint8_t *>(tlv);
-
-        app::CommandPathParams cmdParams = { endpointId, /* group id */ 0, clusterId, commandId,
-                                             (app::CommandPathFlags::kEndpointIdValid) };
-
-        CommandSender::AdditionalCommandParameters additionalParams;
-
-        SuccessOrExit(err = sender->PrepareCommand(cmdParams, additionalParams));
-        {
-            auto writer = sender->GetCommandDataIBTLVWriter();
-            VerifyOrExit(writer != nullptr, err = CHIP_ERROR_INCORRECT_STATE);
-            TLV::TLVReader reader;
-            reader.Init(tlvBuffer, static_cast<uint32_t>(tlvLength));
-            reader.Next();
-            SuccessOrExit(err = writer->CopyContainer(TLV::ContextTag(CommandDataIB::Tag::kFields), reader));
-        }
-
-        SuccessOrExit(err = sender->FinishCommand(timedRequestTimeoutMs != 0 ? Optional<uint16_t>(timedRequestTimeoutMs)
-                                                                             : Optional<uint16_t>::Missing(),
-                                                  additionalParams));
-
-        // CommandSender provides us with the CommandReference for this associated command. In order to match responses
-        // we have to add CommandRef to index lookup.
-        VerifyOrExit(additionalParams.commandRef.HasValue(), err = CHIP_ERROR_INVALID_ARGUMENT);
-        SuccessOrExit(err = callback->AddCommandRefToIndexLookup(additionalParams.commandRef.Value(), i));
-    }
-
-    SuccessOrExit(err = sender->SendCommandRequest(device->GetSecureSession().Value(),
-                                                   interactionTimeoutMs != 0
-                                                       ? MakeOptional(System::Clock::Milliseconds32(interactionTimeoutMs))
-                                                       : Optional<System::Clock::Timeout>::Missing()));
-
-    sender.release();
-    callback.release();
-
-    // TODO(#30985): Reconsider the purpose of busyWait and if it can be broken out into it's
-    // own method/primitive.
-    if (busyWaitMs)
-    {
-        usleep(busyWaitMs * 1000);
-    }
-
-exit:
-    return ToPyChipError(err);
+PyChipError pychip_CommandSender_TestOnlySendBatchCommands(void * appContext, DeviceProxy * device, uint16_t timedRequestTimeoutMs,
+                                                           uint16_t interactionTimeoutMs, uint16_t busyWaitMs,
+                                                           bool suppressResponse,
+                                                           python::TestOnlyPyBatchCommandsOverrides testOnlyOverrides,
+                                                           python::PyInvokeRequestData * batchCommandData, size_t length)
+{
+#if CONFIG_BUILD_FOR_HOST_UNIT_TEST
+    return SendBatchCommandsInternal(appContext, device, timedRequestTimeoutMs, interactionTimeoutMs, busyWaitMs, suppressResponse,
+                                     &testOnlyOverrides, batchCommandData, length);
+#else
+    return ToPyChipError(CHIP_ERROR_NOT_IMPLEMENTED);
+#endif
 }
 
 PyChipError pychip_CommandSender_TestOnlySendCommandTimedRequestNoTimedInvoke(
diff --git a/src/controller/python/chip/interaction_model/Delegate.h b/src/controller/python/chip/interaction_model/Delegate.h
index f11f493..72832a7 100644
--- a/src/controller/python/chip/interaction_model/Delegate.h
+++ b/src/controller/python/chip/interaction_model/Delegate.h
@@ -60,6 +60,17 @@
     size_t tlvLength;
 };
 
+struct TestOnlyPyBatchCommandsOverrides
+{
+    // When max paths per invoke override value is set to 0, we will not use
+    // it as an override. Otherwise, this value will be provided to the
+    // CommandSender as the remote node's maximum paths.
+    uint16_t overrideRemoteMaxPathsPerInvoke;
+    bool suppressTimedRequestMessage;
+    uint16_t * overrideCommandRefsList;
+    size_t overrideCommandRefsListLength;
+};
+
 } // namespace python
 
 namespace Controller {
diff --git a/src/controller/python/chip/interaction_model/__init__.py b/src/controller/python/chip/interaction_model/__init__.py
index 7856856..f7239d7 100644
--- a/src/controller/python/chip/interaction_model/__init__.py
+++ b/src/controller/python/chip/interaction_model/__init__.py
@@ -27,11 +27,12 @@
 from chip.exceptions import ChipStackException
 
 from .delegate import (AttributePath, AttributePathIBstruct, DataVersionFilterIBstruct, EventPath, EventPathIBstruct,
-                       PyInvokeRequestData, PyWriteAttributeData, SessionParameters, SessionParametersStruct)
+                       PyInvokeRequestData, PyWriteAttributeData, SessionParameters, SessionParametersStruct,
+                       TestOnlyPyBatchCommandsOverrides)
 
 __all__ = ["AttributePath", "AttributePathIBstruct", "DataVersionFilterIBstruct",
            "EventPath", "EventPathIBstruct", "InteractionModelError", "PyInvokeRequestData",
-           "PyWriteAttributeData", "SessionParameters", "SessionParametersStruct", "Status"]
+           "PyWriteAttributeData", "SessionParameters", "SessionParametersStruct", "Status", "TestOnlyPyBatchCommandsOverrides"]
 
 
 # defined src/controller/python/chip/interaction_model/Delegate.h
diff --git a/src/controller/python/chip/interaction_model/delegate.py b/src/controller/python/chip/interaction_model/delegate.py
index a9a9c04..d0ed641 100644
--- a/src/controller/python/chip/interaction_model/delegate.py
+++ b/src/controller/python/chip/interaction_model/delegate.py
@@ -17,7 +17,7 @@
 import ctypes
 import threading
 import typing
-from ctypes import CFUNCTYPE, c_uint8, c_uint32, c_uint64, c_void_p
+from ctypes import CFUNCTYPE, POINTER, c_uint8, c_uint32, c_uint64, c_void_p
 from dataclasses import dataclass
 
 import chip.exceptions
@@ -199,6 +199,25 @@
     _fields_ = [('attributePath', PyAttributePath), ('tlvData', ctypes.c_void_p), ('tlvLength', ctypes.c_size_t)]
 
 
+class TestOnlyPyBatchCommandsOverrides(ctypes.Structure):
+    ''' TestOnly struct for overriding aspects of batch command to send invalid commands.
+
+    We are using the following struct for passing the information of TestOnlyPyBatchCommandsOverrides between Python and C++:
+
+    ```c
+    struct TestOnlyPyBatchCommandsOverrides
+    {
+        uint16_t overrideRemoteMaxPathsPerInvoke;
+        bool suppressTimedRequestMessage;
+        uint16_t * overrideCommandRefsList;
+        size_t overrideCommandRefsListLength;
+    };
+    ```
+    '''
+    _fields_ = [('overrideRemoteMaxPathsPerInvoke', ctypes.c_uint16), ('suppressTimedRequestMessage', ctypes.c_bool),
+                ('overrideCommandRefsList', POINTER(ctypes.c_uint16)), ('overrideCommandRefsListLength', ctypes.c_size_t)]
+
+
 # typedef void (*PythonInteractionModelDelegate_OnCommandResponseStatusCodeReceivedFunct)(uint64_t commandSenderPtr,
 #                                                                                         void * commandStatusBuf);
 # typedef void (*PythonInteractionModelDelegate_OnCommandResponseProtocolErrorFunct)(uint64_t commandSenderPtr,