Allow chip-repl to send group commands (#25158)

* Allow chip-repl to send group commands

With chip-repl now able to send we have updated the yamltests runner
that uses chip-repl to send group commands

* Address PR comments

* Restyle

* Address PR comments

* Restyle
diff --git a/src/controller/python/BUILD.gn b/src/controller/python/BUILD.gn
index 82bc673..da9b080 100644
--- a/src/controller/python/BUILD.gn
+++ b/src/controller/python/BUILD.gn
@@ -51,6 +51,8 @@
 
   sources += [ "chip/native/CommonStackInit.cpp" ]
 
+  defines = []
+
   if (chip_controller) {
     sources += [
       "ChipCommissionableNodeController-ScriptBinding.cpp",
@@ -76,6 +78,8 @@
       "chip/native/PyChipError.cpp",
       "chip/utils/DeviceProxyUtils.cpp",
     ]
+    defines += [ "CHIP_CONFIG_MAX_GROUPS_PER_FABRIC=50" ]
+    defines += [ "CHIP_CONFIG_MAX_GROUP_KEYS_PER_FABRIC=50" ]
   } else {
     sources += [
       "chip/server/Options.cpp",
diff --git a/src/controller/python/ChipDeviceController-ScriptBinding.cpp b/src/controller/python/ChipDeviceController-ScriptBinding.cpp
index e9cf070..eaced94 100644
--- a/src/controller/python/ChipDeviceController-ScriptBinding.cpp
+++ b/src/controller/python/ChipDeviceController-ScriptBinding.cpp
@@ -238,6 +238,7 @@
     sGroupDataProvider.SetStorageDelegate(storageAdapter);
     sGroupDataProvider.SetSessionKeystore(factoryParams.sessionKeystore);
     PyReturnErrorOnFailure(ToPyChipError(sGroupDataProvider.Init()));
+    Credentials::SetGroupDataProvider(&sGroupDataProvider);
     factoryParams.groupDataProvider = &sGroupDataProvider;
 
     PyReturnErrorOnFailure(ToPyChipError(sPersistentStorageOpCertStore.Init(storageAdapter)));
diff --git a/src/controller/python/OpCredsBinding.cpp b/src/controller/python/OpCredsBinding.cpp
index 3b12f8f..c7f3e1d 100644
--- a/src/controller/python/OpCredsBinding.cpp
+++ b/src/controller/python/OpCredsBinding.cpp
@@ -465,6 +465,21 @@
     return ToPyChipError(CHIP_NO_ERROR);
 }
 
+PyChipError pychip_OpCreds_InitGroupTestingData(chip::Controller::DeviceCommissioner * devCtrl)
+{
+    VerifyOrReturnError(devCtrl != nullptr, ToPyChipError(CHIP_ERROR_INVALID_ARGUMENT));
+
+    uint8_t compressedFabricId[sizeof(uint64_t)] = { 0 };
+    chip::MutableByteSpan compressedFabricIdSpan(compressedFabricId);
+
+    CHIP_ERROR err = devCtrl->GetCompressedFabricIdBytes(compressedFabricIdSpan);
+    VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err));
+
+    err = chip::GroupTesting::InitData(&sGroupDataProvider, devCtrl->GetFabricIndex(), compressedFabricIdSpan);
+
+    return ToPyChipError(err);
+}
+
 PyChipError pychip_OpCreds_SetMaximallyLargeCertsUsed(OpCredsContext * context, bool enabled)
 {
     VerifyOrReturnError(context != nullptr && context->mAdapter != nullptr, ToPyChipError(CHIP_ERROR_INCORRECT_STATE));
diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py
index 96e86ad..f373166 100644
--- a/src/controller/python/chip/ChipDeviceCtrl.py
+++ b/src/controller/python/chip/ChipDeviceCtrl.py
@@ -894,6 +894,18 @@
             ), payload, timedRequestTimeoutMs=timedRequestTimeoutMs, interactionTimeoutMs=interactionTimeoutMs, busyWaitMs=busyWaitMs).raise_on_error()
         return await future
 
+    def SendGroupCommand(self, groupid: int, payload: ClusterObjects.ClusterCommand, busyWaitMs: typing.Union[None, int] = None):
+        '''
+        Send a group cluster-object encapsulated command to a group_id and get returned a future that can be awaited upon to get confirmation command was sent.
+        '''
+        self.CheckIsActive()
+
+        ClusterCommand.SendGroupCommand(
+            groupid, self.devCtrl, payload, busyWaitMs=busyWaitMs).raise_on_error()
+
+        # None is the expected return for sending group commands.
+        return None
+
     async def WriteAttribute(self, nodeid: int, attributes: typing.List[typing.Tuple[int, ClusterObjects.ClusterAttributeDescriptor, int]], timedRequestTimeoutMs: typing.Union[None, int] = None, interactionTimeoutMs: typing.Union[None, int] = None, busyWaitMs: typing.Union[None, int] = None):
         '''
         Write a list of attributes on a target node.
@@ -1289,6 +1301,15 @@
                 self.devCtrl, py_object(self), csr.NOCSRElements, len(csr.NOCSRElements), nodeId)
         )
 
+    def InitGroupTestingData(self):
+        """Populates the Device Controller's GroupDataProvider with known test group info and keys."""
+        self.CheckIsActive()
+
+        self._ChipStack.Call(
+            lambda: self._dmLib.pychip_OpCreds_InitGroupTestingData(
+                self.devCtrl)
+        ).raise_on_error()
+
     # ----- Private Members -----
     def _InitLib(self):
         if self._dmLib is None:
@@ -1455,6 +1476,10 @@
             ]
             self._dmLib.pychip_DeviceController_IssueNOCChain.restype = PyChipError
 
+            self._dmLib.pychip_OpCreds_InitGroupTestingData.argtypes = [
+                c_void_p]
+            self._dmLib.pychip_OpCreds_InitGroupTestingData.restype = PyChipError
+
             self._dmLib.pychip_DeviceController_SetIssueNOCChainCallbackPythonCallback.argtypes = [
                 _IssueNOCChainCallbackPythonCallbackFunct]
             self._dmLib.pychip_DeviceController_SetIssueNOCChainCallbackPythonCallback.restype = None
diff --git a/src/controller/python/chip/clusters/Command.py b/src/controller/python/chip/clusters/Command.py
index 056688f..df20371 100644
--- a/src/controller/python/chip/clusters/Command.py
+++ b/src/controller/python/chip/clusters/Command.py
@@ -175,6 +175,22 @@
         ))
 
 
+def SendGroupCommand(groupId: int, devCtrl: c_void_p, payload: ClusterCommand, busyWaitMs: Union[None, int] = None) -> PyChipError:
+    ''' Send a cluster-object encapsulated group command to a device and does the following:
+            - None (on a successful response containing no data)
+            - Raises an exception if any errors are encountered.
+    '''
+    handle = chip.native.GetLibraryHandle()
+
+    payloadTLV = payload.ToTLV()
+    return builtins.chipStack.Call(
+        lambda: handle.pychip_CommandSender_SendGroupCommand(
+            c_uint16(groupId), devCtrl,
+            payload.cluster_id, payload.command_id, payloadTLV, len(payloadTLV),
+            ctypes.c_uint16(0 if busyWaitMs is None else busyWaitMs),
+        ))
+
+
 def Init():
     handle = chip.native.GetLibraryHandle()
 
@@ -185,6 +201,8 @@
 
         setter.Set('pychip_CommandSender_SendCommand',
                    PyChipError, [py_object, c_void_p, c_uint16, c_uint32, c_uint32, c_char_p, c_size_t, c_uint16])
+        setter.Set('pychip_CommandSender_SendGroupCommand',
+                   PyChipError, [c_uint16, c_void_p, c_uint32, c_uint32, c_char_p, c_size_t, c_uint16])
         setter.Set('pychip_CommandSender_InitCallbacks', None, [
                    _OnCommandSenderResponseCallbackFunct, _OnCommandSenderErrorCallbackFunct, _OnCommandSenderDoneCallbackFunct])
 
diff --git a/src/controller/python/chip/clusters/command.cpp b/src/controller/python/chip/clusters/command.cpp
index 169b925..0c812c4 100644
--- a/src/controller/python/chip/clusters/command.cpp
+++ b/src/controller/python/chip/clusters/command.cpp
@@ -37,6 +37,10 @@
                                              chip::EndpointId endpointId, chip::ClusterId clusterId, chip::CommandId commandId,
                                              const uint8_t * payload, size_t length, uint16_t interactionTimeoutMs,
                                              uint16_t busyWaitMs);
+
+PyChipError pychip_CommandSender_SendGroupCommand(chip::GroupId groupId, chip::Controller::DeviceCommissioner * devCtrl,
+                                                  chip::ClusterId clusterId, chip::CommandId commandId, const uint8_t * payload,
+                                                  size_t length, uint16_t busyWaitMs);
 }
 
 namespace chip {
@@ -171,4 +175,46 @@
 exit:
     return ToPyChipError(err);
 }
+
+PyChipError pychip_CommandSender_SendGroupCommand(chip::GroupId groupId, chip::Controller::DeviceCommissioner * devCtrl,
+                                                  chip::ClusterId clusterId, chip::CommandId commandId, const uint8_t * payload,
+                                                  size_t length, uint16_t busyWaitMs)
+{
+    CHIP_ERROR err = CHIP_NO_ERROR;
+
+    chip::Messaging::ExchangeManager * exchangeManager = chip::app::InteractionModelEngine::GetInstance()->GetExchangeManager();
+    VerifyOrReturnError(exchangeManager != nullptr, ToPyChipError(CHIP_ERROR_INCORRECT_STATE));
+
+    std::unique_ptr<CommandSender> sender = std::make_unique<CommandSender>(nullptr /* callback */, exchangeManager);
+
+    app::CommandPathParams cmdParams = { groupId, clusterId, commandId, (app::CommandPathFlags::kGroupIdValid) };
+
+    SuccessOrExit(err = sender->PrepareCommand(cmdParams, false));
+
+    {
+        auto writer = sender->GetCommandDataIBTLVWriter();
+        TLV::TLVReader reader;
+        VerifyOrExit(writer != nullptr, err = CHIP_ERROR_INCORRECT_STATE);
+        reader.Init(payload, length);
+        reader.Next();
+        SuccessOrExit(writer->CopyContainer(TLV::ContextTag(to_underlying(CommandDataIB::Tag::kFields)), reader));
+    }
+
+    SuccessOrExit(err = sender->FinishCommand(Optional<uint16_t>::Missing()));
+
+    {
+        auto fabricIndex = devCtrl->GetFabricIndex();
+
+        chip::Transport::OutgoingGroupSession session(groupId, fabricIndex);
+        SuccessOrExit(err = sender->SendGroupCommandRequest(chip::SessionHandle(session)));
+    }
+
+    if (busyWaitMs)
+    {
+        usleep(busyWaitMs * 1000);
+    }
+
+exit:
+    return ToPyChipError(err);
+}
 }
diff --git a/src/controller/python/chip/yaml/runner.py b/src/controller/python/chip/yaml/runner.py
index b3559a1..8780c2b 100644
--- a/src/controller/python/chip/yaml/runner.py
+++ b/src/controller/python/chip/yaml/runner.py
@@ -156,6 +156,11 @@
         self._expected_response_object = None
         self._endpoint = test_step.endpoint
         self._node_id = test_step.node_id
+        self._group_id = test_step.group_id
+
+        if self._node_id is None and self._group_id is None:
+            raise UnexpectedParsingError(
+                'Both node_id and group_id are None, at least one needs to be provided')
 
         command = context.data_model_lookup.get_command(self._cluster, self._command_name)
 
@@ -182,10 +187,15 @@
 
     def run_action(self, dev_ctrl: ChipDeviceController) -> _ActionResult:
         try:
-            resp = asyncio.run(dev_ctrl.SendCommand(
-                self._node_id, self._endpoint, self._request_object,
-                timedRequestTimeoutMs=self._interation_timeout_ms,
-                busyWaitMs=self._busy_wait_ms))
+            if self._group_id:
+                resp = dev_ctrl.SendGroupCommand(
+                    self._group_id, self._request_object,
+                    busyWaitMs=self._busy_wait_ms)
+            else:
+                resp = asyncio.run(dev_ctrl.SendCommand(
+                    self._node_id, self._endpoint, self._request_object,
+                    timedRequestTimeoutMs=self._interation_timeout_ms,
+                    busyWaitMs=self._busy_wait_ms))
         except chip.interaction_model.InteractionModelError as error:
             return _ActionResult(status=_ActionStatus.ERROR, response=error)
 
@@ -736,6 +746,7 @@
         self._certificate_authority_manager = certificate_authority_manager
         self._dev_ctrls = {}
 
+        alpha_dev_ctrl.InitGroupTestingData()
         self._dev_ctrls['alpha'] = alpha_dev_ctrl
 
     def _invoke_action_factory(self, test_step, cluster: str):
@@ -1014,6 +1025,7 @@
                     fabric = certificate_authority.NewFabricAdmin(vendorId=0xFFF1,
                                                                   fabricId=fabric_id)
                 dev_ctrl = fabric.NewController()
+                dev_ctrl.InitGroupTestingData()
                 self._dev_ctrls[action.identity] = dev_ctrl
         else:
             dev_ctrl = self._dev_ctrls['alpha']