TC-IDM-1.2 (#27024)

* TC-IDM-1.2

Adds automation for TC-IDM-1.2
Also Adds suppressResponse to CommandSender as well as a test-only
function to test timedResponse flag with no corresponding
TimedInvoke action + plumbing through the python layers

* Restyled by isort

* Updates from review comments

* Couple formatting fixes

* Cleanup.

* Add a port to pase in python, fix filtering

* Consolidate CommandSender functions

* Timed invoke can be inferred

---------

Co-authored-by: Restyled.io <commits@restyled.io>
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index b8e5afb..78c4070 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -448,6 +448,7 @@
                   scripts/run_in_python_env.sh out/venv './scripts/tests/run_python_test.py --app out/linux-x64-all-clusters-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-all-clusters-app  --factoryreset --app-args "--discriminator 1234 --KVS kvs1 --trace_decode 1" --script "src/python_testing/TC_CGEN_2_4.py" --script-args "--storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021"'
                   scripts/run_in_python_env.sh out/venv './scripts/tests/run_python_test.py --app out/linux-x64-all-clusters-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-all-clusters-app  --factoryreset --app-args "--discriminator 1234 --KVS kvs1 --trace_decode 1" --script "src/python_testing/TC_DA_1_2.py" --script-args "--storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --PICS src/app/tests/suites/certification/ci-pics-values"'
                   scripts/run_in_python_env.sh out/venv './scripts/tests/run_python_test.py --app out/linux-x64-all-clusters-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-all-clusters-app  --factoryreset --app-args "--discriminator 1234 --KVS kvs1 --trace_decode 1" --script "src/python_testing/TC_DA_1_5.py" --script-args "--storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --PICS src/app/tests/suites/certification/ci-pics-values"'
+                  scripts/run_in_python_env.sh out/venv './scripts/tests/run_python_test.py --app out/linux-x64-all-clusters-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-all-clusters-app  --factoryreset --app-args "--discriminator 1234 --KVS kvs1 --trace_decode 1" --script "src/python_testing/TC_IDM_1_2.py" --script-args "--storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021"'
                   scripts/run_in_python_env.sh out/venv './scripts/tests/run_python_test.py --script "src/python_testing/TestMatterTestingSupport.py"'
             - name: Uploading core files
               uses: actions/upload-artifact@v3
diff --git a/config/python/CHIPProjectConfig.h b/config/python/CHIPProjectConfig.h
index 87940a9..5effaaa 100644
--- a/config/python/CHIPProjectConfig.h
+++ b/config/python/CHIPProjectConfig.h
@@ -59,4 +59,6 @@
 #define CHIP_DEVICE_CONFIG_ENABLE_COMMISSIONER_DISCOVERY 1
 #define CHIP_DEVICE_CONFIG_ENABLE_BOTH_COMMISSIONER_AND_COMMISSIONEE 1
 
+#define CONFIG_BUILD_FOR_HOST_UNIT_TEST 1
+
 #endif /* CHIPPROJECTCONFIG_H */
diff --git a/src/app/CommandSender.cpp b/src/app/CommandSender.cpp
index 5282f10..5daceaf 100644
--- a/src/app/CommandSender.cpp
+++ b/src/app/CommandSender.cpp
@@ -32,9 +32,10 @@
 namespace chip {
 namespace app {
 
-CommandSender::CommandSender(Callback * apCallback, Messaging::ExchangeManager * apExchangeMgr, bool aIsTimedRequest) :
-    mExchangeCtx(*this), mpCallback(apCallback), mpExchangeMgr(apExchangeMgr), mSuppressResponse(false),
-    mTimedRequest(aIsTimedRequest)
+CommandSender::CommandSender(Callback * apCallback, Messaging::ExchangeManager * apExchangeMgr, bool aIsTimedRequest,
+                             bool aSuppressResponse) :
+    mExchangeCtx(*this),
+    mpCallback(apCallback), mpExchangeMgr(apExchangeMgr), mSuppressResponse(aSuppressResponse), mTimedRequest(aIsTimedRequest)
 {}
 
 CHIP_ERROR CommandSender::AllocateBuffer()
@@ -61,7 +62,7 @@
     return CHIP_NO_ERROR;
 }
 
-CHIP_ERROR CommandSender::SendCommandRequest(const SessionHandle & session, Optional<System::Clock::Timeout> timeout)
+CHIP_ERROR CommandSender::SendCommandRequestInternal(const SessionHandle & session, Optional<System::Clock::Timeout> timeout)
 {
     VerifyOrReturnError(mState == State::AddedCommand, CHIP_ERROR_INCORRECT_STATE);
 
@@ -76,15 +77,6 @@
 
     mExchangeCtx->SetResponseTimeout(timeout.ValueOr(session->ComputeRoundTripTimeout(app::kExpectedIMProcessingTime)));
 
-    if (mTimedRequest != mTimedInvokeTimeoutMs.HasValue())
-    {
-        ChipLogError(
-            DataManagement,
-            "Inconsistent timed request state in CommandSender: mTimedRequest (%d) != mTimedInvokeTimeoutMs.HasValue() (%d)",
-            mTimedRequest, mTimedInvokeTimeoutMs.HasValue());
-        return CHIP_ERROR_INCORRECT_STATE;
-    }
-
     if (mTimedInvokeTimeoutMs.HasValue())
     {
         ReturnErrorOnFailure(TimedRequest::Send(mExchangeCtx.Get(), mTimedInvokeTimeoutMs.Value()));
@@ -95,6 +87,29 @@
     return SendInvokeRequest();
 }
 
+#if CONFIG_BUILD_FOR_HOST_UNIT_TEST
+CHIP_ERROR CommandSender::TestOnlyCommandSenderTimedRequestFlagWithNoTimedInvoke(const SessionHandle & session,
+                                                                                 Optional<System::Clock::Timeout> timeout)
+{
+    VerifyOrReturnError(mTimedRequest, CHIP_ERROR_INCORRECT_STATE);
+    return SendCommandRequestInternal(session, timeout);
+}
+#endif
+
+CHIP_ERROR CommandSender::SendCommandRequest(const SessionHandle & session, Optional<System::Clock::Timeout> timeout)
+{
+
+    if (mTimedRequest != mTimedInvokeTimeoutMs.HasValue())
+    {
+        ChipLogError(
+            DataManagement,
+            "Inconsistent timed request state in CommandSender: mTimedRequest (%d) != mTimedInvokeTimeoutMs.HasValue() (%d)",
+            mTimedRequest, mTimedInvokeTimeoutMs.HasValue());
+        return CHIP_ERROR_INCORRECT_STATE;
+    }
+    return SendCommandRequestInternal(session, timeout);
+}
+
 CHIP_ERROR CommandSender::SendGroupCommandRequest(const SessionHandle & session)
 {
     VerifyOrReturnError(mState == State::AddedCommand, CHIP_ERROR_INCORRECT_STATE);
diff --git a/src/app/CommandSender.h b/src/app/CommandSender.h
index c61043c..6da9b05 100644
--- a/src/app/CommandSender.h
+++ b/src/app/CommandSender.h
@@ -121,7 +121,8 @@
      * If used in a groups setting, callbacks do not need to be passed.
      * If callbacks are passed the only one that will be called in a group sesttings is the onDone
      */
-    CommandSender(Callback * apCallback, Messaging::ExchangeManager * apExchangeMgr, bool aIsTimedRequest = false);
+    CommandSender(Callback * apCallback, Messaging::ExchangeManager * apExchangeMgr, bool aIsTimedRequest = false,
+                  bool aSuppressResponse = false);
     CHIP_ERROR PrepareCommand(const CommandPathParams & aCommandPathParams, bool aStartDataStruct = true);
     CHIP_ERROR FinishCommand(bool aEndDataStruct = true);
     TLV::TLVWriter * GetCommandDataIBTLVWriter();
@@ -164,11 +165,18 @@
      */
     template <typename CommandDataT>
     CHIP_ERROR AddRequestDataNoTimedCheck(const CommandPathParams & aCommandPath, const CommandDataT & aData,
-                                          const Optional<uint16_t> & aTimedInvokeTimeoutMs, bool aSuppressResponse = false)
+                                          const Optional<uint16_t> & aTimedInvokeTimeoutMs)
     {
-        mSuppressResponse = aSuppressResponse;
         return AddRequestDataInternal(aCommandPath, aData, aTimedInvokeTimeoutMs);
     }
+
+    /**
+     * Version of SendCommandRequest that sets the TimedRequest flag but does not send the TimedInvoke
+     * action. For use in tests only.
+     */
+    CHIP_ERROR TestOnlyCommandSenderTimedRequestFlagWithNoTimedInvoke(const SessionHandle & session,
+                                                                      Optional<System::Clock::Timeout> timeout = NullOptional);
+
 #endif // CONFIG_BUILD_FOR_HOST_UNIT_TEST
 
 private:
@@ -265,6 +273,8 @@
 
     CHIP_ERROR Finalize(System::PacketBufferHandle & commandPacket);
 
+    CHIP_ERROR SendCommandRequestInternal(const SessionHandle & session, Optional<System::Clock::Timeout> timeout);
+
     Messaging::ExchangeHolder mExchangeCtx;
     Callback * mpCallback                      = nullptr;
     Messaging::ExchangeManager * mpExchangeMgr = nullptr;
diff --git a/src/app/tests/suites/commands/interaction_model/InteractionModel.h b/src/app/tests/suites/commands/interaction_model/InteractionModel.h
index 1d3e2eb..846dd6a 100644
--- a/src/app/tests/suites/commands/interaction_model/InteractionModel.h
+++ b/src/app/tests/suites/commands/interaction_model/InteractionModel.h
@@ -234,12 +234,11 @@
 
             chip::app::CommandPathParams commandPath = { endpointId, clusterId, commandId,
                                                          (chip::app::CommandPathFlags::kEndpointIdValid) };
-            auto commandSender = std::make_unique<chip::app::CommandSender>(mCallback, device->GetExchangeManager(),
-                                                                            mTimedInteractionTimeoutMs.HasValue());
+            auto commandSender                       = std::make_unique<chip::app::CommandSender>(
+                mCallback, device->GetExchangeManager(), mTimedInteractionTimeoutMs.HasValue(), mSuppressResponse.ValueOr(false));
             VerifyOrReturnError(commandSender != nullptr, CHIP_ERROR_NO_MEMORY);
 
-            ReturnErrorOnFailure(commandSender->AddRequestDataNoTimedCheck(commandPath, value, mTimedInteractionTimeoutMs,
-                                                                           mSuppressResponse.ValueOr(false)));
+            ReturnErrorOnFailure(commandSender->AddRequestDataNoTimedCheck(commandPath, value, mTimedInteractionTimeoutMs));
             ReturnErrorOnFailure(commandSender->SendCommandRequest(device->GetSecureSession().Value()));
             mCommandSender.push_back(std::move(commandSender));
 
diff --git a/src/controller/python/ChipDeviceController-ScriptBinding.cpp b/src/controller/python/ChipDeviceController-ScriptBinding.cpp
index 39d4fa5..e69ad2d 100644
--- a/src/controller/python/ChipDeviceController-ScriptBinding.cpp
+++ b/src/controller/python/ChipDeviceController-ScriptBinding.cpp
@@ -139,7 +139,7 @@
 PyChipError pychip_DeviceController_SetWiFiCredentials(const char * ssid, const char * credentials);
 PyChipError pychip_DeviceController_CloseSession(chip::Controller::DeviceCommissioner * devCtrl, chip::NodeId nodeid);
 PyChipError pychip_DeviceController_EstablishPASESessionIP(chip::Controller::DeviceCommissioner * devCtrl, const char * peerAddrStr,
-                                                           uint32_t setupPINCode, chip::NodeId nodeid);
+                                                           uint32_t setupPINCode, chip::NodeId nodeid, uint16_t port);
 PyChipError pychip_DeviceController_EstablishPASESessionBLE(chip::Controller::DeviceCommissioner * devCtrl, uint32_t setupPINCode,
                                                             uint16_t discriminator, chip::NodeId nodeid);
 PyChipError pychip_DeviceController_Commission(chip::Controller::DeviceCommissioner * devCtrl, chip::NodeId nodeid);
@@ -512,13 +512,17 @@
 }
 
 PyChipError pychip_DeviceController_EstablishPASESessionIP(chip::Controller::DeviceCommissioner * devCtrl, const char * peerAddrStr,
-                                                           uint32_t setupPINCode, chip::NodeId nodeid)
+                                                           uint32_t setupPINCode, chip::NodeId nodeid, uint16_t port)
 {
     chip::Inet::IPAddress peerAddr;
     chip::Transport::PeerAddress addr;
     RendezvousParameters params = chip::RendezvousParameters().SetSetupPINCode(setupPINCode);
     VerifyOrReturnError(chip::Inet::IPAddress::FromString(peerAddrStr, peerAddr), ToPyChipError(CHIP_ERROR_INVALID_ARGUMENT));
     addr.SetTransportType(chip::Transport::Type::kUdp).SetIPAddress(peerAddr);
+    if (port != 0)
+    {
+        addr.SetPort(port);
+    }
     params.SetPeerAddress(addr).SetDiscriminator(0);
     sPairingDelegate.SetExpectingPairingComplete(true);
     return ToPyChipError(devCtrl->EstablishPASEConnection(nodeid, params));
diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py
index 1022182..37437ea 100644
--- a/src/controller/python/chip/ChipDeviceCtrl.py
+++ b/src/controller/python/chip/ChipDeviceCtrl.py
@@ -473,13 +473,13 @@
                 self.devCtrl, setupPinCode, discriminator, nodeid)
         )
 
-    def EstablishPASESessionIP(self, ipaddr: str, setupPinCode: int, nodeid: int):
+    def EstablishPASESessionIP(self, ipaddr: str, setupPinCode: int, nodeid: int, port: int = 0):
         self.CheckIsActive()
 
         self.state = DCState.RENDEZVOUS_ONGOING
         return self._ChipStack.CallAsync(
             lambda: self._dmLib.pychip_DeviceController_EstablishPASESessionIP(
-                self.devCtrl, ipaddr.encode("utf-8"), setupPinCode, nodeid)
+                self.devCtrl, ipaddr.encode("utf-8"), setupPinCode, nodeid, port)
         )
 
     def GetTestCommissionerUsed(self):
@@ -779,9 +779,30 @@
             device.deviceProxy, upperLayerProcessingTimeoutMs))
         return res
 
+    async def TestOnlySendCommandTimedRequestFlagWithNoTimedInvoke(self, nodeid: int, endpoint: int,
+                                                                   payload: ClusterObjects.ClusterCommand, responseType=None):
+        '''
+
+        Please see SendCommand for description.
+        '''
+        self.CheckIsActive()
+
+        eventLoop = asyncio.get_running_loop()
+        future = eventLoop.create_future()
+
+        device = self.GetConnectedDeviceSync(nodeid, timeoutMs=None)
+        ClusterCommand.TestOnlySendCommandTimedRequestFlagWithNoTimedInvoke(
+            future, eventLoop, responseType, device.deviceProxy, ClusterCommand.CommandPath(
+                EndpointId=endpoint,
+                ClusterId=payload.cluster_id,
+                CommandId=payload.command_id,
+            ), payload).raise_on_error()
+        return await future
+
     async def SendCommand(self, nodeid: int, endpoint: int, payload: ClusterObjects.ClusterCommand, responseType=None,
                           timedRequestTimeoutMs: typing.Union[None, int] = None,
-                          interactionTimeoutMs: typing.Union[None, int] = None, busyWaitMs: typing.Union[None, int] = None):
+                          interactionTimeoutMs: typing.Union[None, int] = None, busyWaitMs: typing.Union[None, int] = None,
+                          suppressResponse: typing.Union[None, bool] = None):
         '''
         Send a cluster-object encapsulated command to a node and get returned a future that can be awaited upon to receive
         the response. If a valid responseType is passed in, that will be used to deserialize the object. If not,
@@ -803,7 +824,7 @@
                 ClusterId=payload.cluster_id,
                 CommandId=payload.command_id,
             ), payload, timedRequestTimeoutMs=timedRequestTimeoutMs,
-            interactionTimeoutMs=interactionTimeoutMs, busyWaitMs=busyWaitMs).raise_on_error()
+            interactionTimeoutMs=interactionTimeoutMs, busyWaitMs=busyWaitMs, suppressResponse=suppressResponse).raise_on_error()
         return await future
 
     def SendGroupCommand(self, groupid: int, payload: ClusterObjects.ClusterCommand, busyWaitMs: typing.Union[None, int] = None):
@@ -1338,7 +1359,7 @@
             self._dmLib.pychip_DeviceController_DiscoverCommissionableNodesCommissioningEnabled.restype = PyChipError
 
             self._dmLib.pychip_DeviceController_EstablishPASESessionIP.argtypes = [
-                c_void_p, c_char_p, c_uint32, c_uint64]
+                c_void_p, c_char_p, c_uint32, c_uint64, c_uint16]
             self._dmLib.pychip_DeviceController_EstablishPASESessionIP.restype = PyChipError
 
             self._dmLib.pychip_DeviceController_EstablishPASESessionBLE.argtypes = [
diff --git a/src/controller/python/chip/clusters/Command.py b/src/controller/python/chip/clusters/Command.py
index 203c92a..af13365 100644
--- a/src/controller/python/chip/clusters/Command.py
+++ b/src/controller/python/chip/clusters/Command.py
@@ -21,7 +21,7 @@
 import logging
 import sys
 from asyncio.futures import Future
-from ctypes import CFUNCTYPE, c_char_p, c_size_t, c_uint8, c_uint16, c_uint32, c_void_p, py_object
+from ctypes import CFUNCTYPE, c_bool, c_char_p, c_size_t, c_uint8, c_uint16, c_uint32, c_void_p, py_object
 from dataclasses import dataclass
 from typing import Type, Union
 
@@ -144,8 +144,30 @@
     ctypes.pythonapi.Py_DecRef(ctypes.py_object(closure))
 
 
+def TestOnlySendCommandTimedRequestFlagWithNoTimedInvoke(future: Future, eventLoop, responseType, device, commandPath, payload):
+    ''' ONLY TO BE USED FOR TEST: Sends the payload with a TimedRequest flag but no TimedInvoke transaction
+    '''
+    if (responseType is not None) and (not issubclass(responseType, ClusterCommand)):
+        raise ValueError("responseType must be a ClusterCommand or None")
+
+    handle = chip.native.GetLibraryHandle()
+    transaction = AsyncCommandTransaction(future, eventLoop, responseType)
+
+    payloadTLV = payload.ToTLV()
+    ctypes.pythonapi.Py_IncRef(ctypes.py_object(transaction))
+    return builtins.chipStack.Call(
+        lambda: handle.pychip_CommandSender_TestOnlySendCommandTimedRequestNoTimedInvoke(
+            ctypes.py_object(transaction), device,
+            commandPath.EndpointId, commandPath.ClusterId, commandPath.CommandId, payloadTLV, len(payloadTLV),
+            ctypes.c_uint16(0),  # interactionTimeoutMs
+            ctypes.c_uint16(0),  # busyWaitMs
+            ctypes.c_bool(False)  # suppressResponse
+        ))
+
+
 def SendCommand(future: Future, eventLoop, responseType: Type, device, commandPath: CommandPath, payload: ClusterCommand,
-                timedRequestTimeoutMs: Union[None, int] = None, interactionTimeoutMs: Union[None, int] = None, busyWaitMs: Union[None, int] = None) -> PyChipError:
+                timedRequestTimeoutMs: Union[None, int] = None, interactionTimeoutMs: Union[None, int] = None, busyWaitMs: Union[None, int] = None,
+                suppressResponse: Union[None, 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)
@@ -175,6 +197,7 @@
             commandPath.ClusterId, commandPath.CommandId, payloadTLV, len(payloadTLV),
             ctypes.c_uint16(0 if interactionTimeoutMs is None else interactionTimeoutMs),
             ctypes.c_uint16(0 if busyWaitMs is None else busyWaitMs),
+            ctypes.c_bool(False if suppressResponse is None else suppressResponse)
         ))
 
 
@@ -203,7 +226,9 @@
         setter = chip.native.NativeLibraryHandleMethodArguments(handle)
 
         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])
+                   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_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',
                    PyChipError, [c_uint16, c_void_p, c_uint32, c_uint32, c_char_p, c_size_t, c_uint16])
         setter.Set('pychip_CommandSender_InitCallbacks', None, [
diff --git a/src/controller/python/chip/clusters/command.cpp b/src/controller/python/chip/clusters/command.cpp
index 468bff5..c824c25 100644
--- a/src/controller/python/chip/clusters/command.cpp
+++ b/src/controller/python/chip/clusters/command.cpp
@@ -36,7 +36,11 @@
 PyChipError pychip_CommandSender_SendCommand(void * appContext, DeviceProxy * device, uint16_t timedRequestTimeoutMs,
                                              chip::EndpointId endpointId, chip::ClusterId clusterId, chip::CommandId commandId,
                                              const uint8_t * payload, size_t length, uint16_t interactionTimeoutMs,
-                                             uint16_t busyWaitMs);
+                                             uint16_t busyWaitMs, bool suppressResponse);
+
+PyChipError pychip_CommandSender_TestOnlySendCommandTimedRequestNoTimedInvoke(
+    void * appContext, DeviceProxy * device, chip::EndpointId endpointId, chip::ClusterId clusterId, chip::CommandId commandId,
+    const uint8_t * payload, size_t length, uint16_t interactionTimeoutMs, uint16_t busyWaitMs, bool suppressResponse);
 
 PyChipError pychip_CommandSender_SendGroupCommand(chip::GroupId groupId, chip::Controller::DeviceCommissioner * devCtrl,
                                                   chip::ClusterId clusterId, chip::CommandId commandId, const uint8_t * payload,
@@ -132,15 +136,16 @@
 PyChipError pychip_CommandSender_SendCommand(void * appContext, DeviceProxy * device, uint16_t timedRequestTimeoutMs,
                                              chip::EndpointId endpointId, chip::ClusterId clusterId, chip::CommandId commandId,
                                              const uint8_t * payload, size_t length, uint16_t interactionTimeoutMs,
-                                             uint16_t busyWaitMs)
+                                             uint16_t busyWaitMs, bool suppressResponse)
 {
     CHIP_ERROR err = CHIP_NO_ERROR;
 
     VerifyOrReturnError(device->GetSecureSession().HasValue(), ToPyChipError(CHIP_ERROR_MISSING_SECURE_SESSION));
 
     std::unique_ptr<CommandSenderCallback> callback = std::make_unique<CommandSenderCallback>(appContext);
-    std::unique_ptr<CommandSender> sender           = std::make_unique<CommandSender>(callback.get(), device->GetExchangeManager(),
-                                                                            /* is timed request */ timedRequestTimeoutMs != 0);
+    std::unique_ptr<CommandSender> sender =
+        std::make_unique<CommandSender>(callback.get(), device->GetExchangeManager(),
+                                        /* is timed request */ timedRequestTimeoutMs != 0, suppressResponse);
 
     app::CommandPathParams cmdParams = { endpointId, /* group id */ 0, clusterId, commandId,
                                          (app::CommandPathFlags::kEndpointIdValid) };
@@ -176,6 +181,56 @@
     return ToPyChipError(err);
 }
 
+PyChipError pychip_CommandSender_TestOnlySendCommandTimedRequestNoTimedInvoke(
+    void * appContext, DeviceProxy * device, chip::EndpointId endpointId, chip::ClusterId clusterId, chip::CommandId commandId,
+    const uint8_t * payload, size_t length, uint16_t interactionTimeoutMs, uint16_t busyWaitMs, bool suppressResponse)
+{
+#if CONFIG_BUILD_FOR_HOST_UNIT_TEST
+
+    CHIP_ERROR err = CHIP_NO_ERROR;
+
+    VerifyOrReturnError(device->GetSecureSession().HasValue(), ToPyChipError(CHIP_ERROR_MISSING_SECURE_SESSION));
+
+    std::unique_ptr<CommandSenderCallback> callback = std::make_unique<CommandSenderCallback>(appContext);
+    std::unique_ptr<CommandSender> sender           = std::make_unique<CommandSender>(callback.get(), device->GetExchangeManager(),
+                                                                            /* is timed request */ true, suppressResponse);
+
+    app::CommandPathParams cmdParams = { endpointId, /* group id */ 0, clusterId, commandId,
+                                         (app::CommandPathFlags::kEndpointIdValid) };
+
+    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(err = writer->CopyContainer(TLV::ContextTag(CommandDataIB::Tag::kFields), reader));
+    }
+
+    SuccessOrExit(err = sender->FinishCommand(false));
+
+    SuccessOrExit(err = sender->TestOnlyCommandSenderTimedRequestFlagWithNoTimedInvoke(
+                      device->GetSecureSession().Value(),
+                      interactionTimeoutMs != 0 ? MakeOptional(System::Clock::Milliseconds32(interactionTimeoutMs))
+                                                : Optional<System::Clock::Timeout>::Missing()));
+
+    sender.release();
+    callback.release();
+
+    if (busyWaitMs)
+    {
+        usleep(busyWaitMs * 1000);
+    }
+
+exit:
+    return ToPyChipError(err);
+#else
+    return ToPyChipError(CHIP_ERROR_NOT_IMPLEMENTED);
+#endif
+}
+
 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)
diff --git a/src/python_testing/TC_IDM_1_2.py b/src/python_testing/TC_IDM_1_2.py
new file mode 100644
index 0000000..7adb7eb
--- /dev/null
+++ b/src/python_testing/TC_IDM_1_2.py
@@ -0,0 +1,285 @@
+#
+#    Copyright (c) 2023 Project CHIP Authors
+#    All rights reserved.
+#
+#    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 inspect
+import logging
+from dataclasses import dataclass
+
+import chip.clusters as Clusters
+import chip.discovery as Discovery
+from chip import ChipUtility
+from chip.exceptions import ChipStackError
+from chip.interaction_model import InteractionModelError, Status
+from matter_testing_support import MatterBaseTest, async_test_body, default_matter_test_main, type_matches
+from mobly import asserts
+
+
+def get_all_cmds_for_cluster_id(cid: int) -> list[Clusters.ClusterObjects.ClusterCommand]:
+    cluster = Clusters.ClusterObjects.ALL_CLUSTERS[cid]
+    try:
+        return inspect.getmembers(cluster.Commands, inspect.isclass)
+    except AttributeError:
+        return []
+
+
+def client_cmd(cmd_class):
+    # Inspect returns all the classes, not just the ones we want, so use a try
+    # here incase we're inspecting a builtin class
+    try:
+        return cmd_class if cmd_class.is_client else None
+    except AttributeError:
+        return None
+
+# one of the steps in this test requires sending a command that requires a timed interaction
+# without first sending the TimedRequest action
+# OpenCommissioningWindow requires a timed invoke and is mandatory on servers, BUT, it's marked
+# that way in the base class. We need a new, fake class that doesn't have that set
+
+
+@dataclass
+class FakeRevokeCommissioning(Clusters.AdministratorCommissioning.Commands.RevokeCommissioning):
+    @ChipUtility.classproperty
+    def must_use_timed_invoke(cls) -> bool:
+        return False
+
+
+class TC_IDM_1_2(MatterBaseTest):
+
+    @async_test_body
+    async def test_TC_IDM_1_2(self):
+        self.print_step(0, "Commissioning - already done")
+        wildcard_descriptor = await self.default_controller.ReadAttribute(self.dut_node_id, [(Clusters.Descriptor)])
+        endpoints = list(wildcard_descriptor.keys())
+        endpoints.sort()
+
+        self.print_step(1, "Send Invoke to unsupported endpoint")
+        # First non-existent endpoint is where the index and and endpoint number don't match
+        non_existent_endpoint = next(i for i, e in enumerate(endpoints + [None]) if i != e)
+        # General Commissioning cluster should be supported on all DUTs, so it will recognize this cluster and
+        # command, but it is sent on an unsupported endpoint
+        cmd = Clusters.GeneralCommissioning.Commands.ArmFailSafe(expiryLengthSeconds=0, breadcrumb=1)
+        try:
+            await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=non_existent_endpoint, payload=cmd)
+            asserts.fail("Unexpected success return from sending command to unsupported endpoint")
+        except InteractionModelError as e:
+            asserts.assert_equal(e.status, Status.UnsupportedEndpoint, "Unexpected error returned from unsupported endpoint")
+
+        self.print_step(2, "Send Invoke to unsupported cluster")
+        all_cluster_ids = list(Clusters.ClusterObjects.ALL_CLUSTERS.keys())
+        unsupported_clusters: dict[int, list[int]] = {}
+        supported_clusters: dict[int, list[int]] = {}
+        for i in endpoints:
+            dut_ep_cluster_ids = wildcard_descriptor[i][Clusters.Descriptor][Clusters.Descriptor.Attributes.ServerList]
+            unsupported_clusters[i] = list(set(all_cluster_ids) - set(dut_ep_cluster_ids))
+            supported_clusters[i] = set(dut_ep_cluster_ids)
+
+        # This is really unlikely to happen on any real product, so we're going to assert here if we can't find anything
+        # since it's likely a test error
+        asserts.assert_true(any(unsupported_clusters[i] for i in endpoints),
+                            "Unable to find any unsupported clusters on any endpoint")
+        asserts.assert_true(any(supported_clusters[i] for i in endpoints), "Unable to find supported clusters on any endpoint")
+
+        sent = False
+        for i in endpoints:
+            if sent:
+                break
+            for cid in unsupported_clusters[i]:
+                cluster = Clusters.ClusterObjects.ALL_CLUSTERS[cid]
+                members = get_all_cmds_for_cluster_id(cid)
+                if not members:
+                    continue
+                # just use the first command with default values
+                name, cmd = members[0]
+                logging.info(f'Sending {name} command to unsupported cluster {cluster} on endpoint {i}')
+                try:
+                    await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=i, payload=cmd())
+                    asserts.fail("Unexpected success return from sending command to unsupported cluster")
+                except InteractionModelError as e:
+                    asserts.assert_equal(e.status, Status.UnsupportedCluster, "Unexpected error returned from unsupported cluster")
+                sent = True
+                break
+        asserts.assert_true(sent, "Unable to find unsupported cluster with commands on any supported endpoint")
+
+        self.print_step(3, "Send Invoke for unsupported command")
+        # First read all the supported commands by wildcard reading the AcceptedCommands attribute from all clusters
+        # We can't wildcard across clusters even if the attribute is the same, so we're going to go 1 by 1.
+        # Just go endpoint by endpoint so we can early exit (each supports different clusters)
+        # TODO: add option to make this a beefier test that does all the commands?
+        sent = False
+        for i in endpoints:
+            if sent:
+                break
+            for cid in supported_clusters[i]:
+                cluster = Clusters.ClusterObjects.ALL_CLUSTERS[cid]
+                logging.info(f'Checking cluster {cluster} ({cid}) on ep {i} for supported commands')
+                members = get_all_cmds_for_cluster_id(cid)
+                if not members:
+                    continue
+
+                dut_supported_ids = await self.read_single_attribute_check_success(cluster=cluster, endpoint=i, attribute=cluster.Attributes.AcceptedCommandList)
+                all_supported_cmds = list(filter(None, [client_cmd(x[1]) for x in members]))
+                all_supported_ids = [x.command_id for x in all_supported_cmds]
+                unsupported_commands = list(set(all_supported_ids) - set(dut_supported_ids))
+                if not unsupported_commands:
+                    continue
+
+                # Let's just use the first unsupported command
+                id = unsupported_commands[0]
+                cmd = next(filter(lambda x: x.command_id == id, all_supported_cmds))
+                try:
+                    ret = await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=i, payload=cmd())
+                    asserts.fail(f'Unexpected success sending unsupported cmd {cmd} to {cluster} cluster on ep {i}')
+                except InteractionModelError as e:
+                    asserts.assert_equal(e.status, Status.UnsupportedCommand, "Unexpected error returned from unsupported command")
+                sent = True
+                break
+
+        # It might actually be the case that all the supported clusters support all the commands. In that case, let's just put a warning.
+        # We could, in theory, send a command with a fully out of bounds command ID, but that's not supported by the controller
+        if not sent:
+            logging.warning("Unable to find a supported cluster with unsupported commands on any endpoint - SKIPPING")
+
+        self.print_step(4, "Setup TH to have no privileges for a cluster, send Invoke")
+        # Setup the ACL
+        acl_only = Clusters.AccessControl.Structs.AccessControlEntryStruct(
+            privilege=Clusters.AccessControl.Enums.AccessControlEntryPrivilegeEnum.kAdminister,
+            authMode=Clusters.AccessControl.Enums.AccessControlEntryAuthModeEnum.kCase,
+            subjects=[self.matter_test_config.controller_node_id],
+            targets=[Clusters.AccessControl.Structs.AccessControlTargetStruct(endpoint=0, cluster=Clusters.AccessControl.id)])
+        result = await self.default_controller.WriteAttribute(self.dut_node_id, [(0, Clusters.AccessControl.Attributes.Acl([acl_only]))])
+        asserts.assert_equal(result[0].Status, Status.Success, "ACL write failed")
+
+        # For the unsupported access test, let's use a cluster that's known to be there and supports commands - general commissioning on EP0
+        cmd = Clusters.GeneralCommissioning.Commands.ArmFailSafe(expiryLengthSeconds=0, breadcrumb=1)
+        try:
+            await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=0, payload=cmd)
+            asserts.fail("Unexpected success return when sending a command with no privileges")
+        except InteractionModelError as e:
+            asserts.assert_equal(e.status, Status.UnsupportedAccess, "Unexpected error returned")
+
+        full_access = Clusters.AccessControl.Structs.AccessControlEntryStruct(
+            privilege=Clusters.AccessControl.Enums.AccessControlEntryPrivilegeEnum.kAdminister,
+            authMode=Clusters.AccessControl.Enums.AccessControlEntryAuthModeEnum.kCase,
+            subjects=[self.matter_test_config.controller_node_id],
+            targets=[])
+        result = await self.default_controller.WriteAttribute(self.dut_node_id, [(0, Clusters.AccessControl.Attributes.Acl([full_access]))])
+        asserts.assert_equal(result[0].Status, Status.Success, "ACL write failed")
+
+        self.print_step(5, "setup TH with no accessing fabric and invoke command")
+        # The only way to have no accessing fabric is to have a PASE session and no added NOC
+        # KeySetRead - fabric scoped command, should not be accessible over PASE
+        # To get a PASE session, we need an open commissioning window
+        discriminator = self.matter_test_config.discriminators[0] + 1
+
+        params = self.default_controller.OpenCommissioningWindow(
+            nodeid=self.dut_node_id, timeout=600, iteration=10000, discriminator=discriminator, option=1)
+
+        # TH2 = new controller that's not connected over CASE
+        new_certificate_authority = self.certificate_authority_manager.NewCertificateAuthority()
+        new_fabric_admin = new_certificate_authority.NewFabricAdmin(vendorId=0xFFF1, fabricId=self.matter_test_config.fabric_id + 1)
+        TH2 = new_fabric_admin.NewController(nodeId=112233)
+
+        devices = TH2.DiscoverCommissionableNodes(
+            filterType=Discovery.FilterType.LONG_DISCRIMINATOR, filter=discriminator, stopOnFirst=False)
+        # For some reason, the devices returned here aren't filtered, so filter ourselves
+        device = next(filter(lambda d: d.commissioningMode == 2 and d.longDiscriminator == discriminator, devices))
+        for a in device.addresses:
+            try:
+                TH2.EstablishPASESessionIP(ipaddr=a, setupPinCode=params.setupPinCode,
+                                           nodeid=self.dut_node_id+1, port=device.port)
+                break
+            except ChipStackError:
+                continue
+
+        try:
+            TH2.GetConnectedDeviceSync(nodeid=self.dut_node_id+1, allowPASE=True, timeoutMs=1000)
+        except TimeoutError:
+            asserts.fail("Unable to establish a PASE session to the device")
+
+        try:
+            # Any group ID is fine since we'll fail before this
+            await TH2.SendCommand(nodeid=self.dut_node_id + 1, endpoint=0, payload=Clusters.GroupKeyManagement.Commands.KeySetRead(groupKeySetID=0x0001))
+            asserts.fail("Incorrectly received a success response from a fabric-scoped command")
+        except InteractionModelError as e:
+            asserts.assert_equal(e.status, Status.UnsupportedAccess, "Incorrect error from fabric-sensitive read over PASE")
+
+        # Cleanup - RevokeCommissioning so we can use ArmFailSafe etc. again.
+        await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=0, payload=Clusters.AdministratorCommissioning.Commands.RevokeCommissioning(), timedRequestTimeoutMs=6000)
+
+        self.print_step(6, "Send invoke request with requires a data response")
+        # ArmFailSafe sends a data response
+        cmd = Clusters.GeneralCommissioning.Commands.ArmFailSafe(expiryLengthSeconds=900, breadcrumb=1)
+        ret = await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=0, payload=cmd)
+        asserts.assert_true(type_matches(ret, Clusters.GeneralCommissioning.Commands.ArmFailSafeResponse),
+                            "Unexpected response type from ArmFailSafe")
+
+        self.print_step(7, "Send a command with suppress Response")
+        # NOTE: This is out of scope currently due to https://github.com/project-chip/connectedhomeip/issues/8043
+        # We perform this step, but the DUT will likely incorrectly send a response
+        # Sending this command at least ensures the DUT doesn't crash with this flag set, even if the behvaior is not correct
+
+        # Lucky candidate ArmFailSafe is at it again - command side effect is to set breadcrumb attribute
+        cmd = Clusters.GeneralCommissioning.Commands.ArmFailSafe(expiryLengthSeconds=900, breadcrumb=2)
+        await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=0, payload=cmd, suppressResponse=True)
+        # TODO: Once the above issue is resolved, this needs a check to ensure that no response was received.
+
+        # Verify that the command had the correct side effect even if a response was sent
+        breadcrumb = await self.read_single_attribute_check_success(
+            cluster=Clusters.GeneralCommissioning, attribute=Clusters.GeneralCommissioning.Attributes.Breadcrumb, endpoint=0)
+        asserts.assert_equal(breadcrumb, 2, "Breadcrumb was not correctly set on ArmFailSafe with response suppressed")
+
+        # Cleanup - Unset the failsafe
+        cmd = Clusters.GeneralCommissioning.Commands.ArmFailSafe(expiryLengthSeconds=0, breadcrumb=0)
+        await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=0, payload=cmd)
+
+        self.print_step(8, "Send Invoke with timedRequest marked, but no timed request sent")
+        # We can do this with any command, but to be thorough, test first with a command that does not
+        # require a timed interaction (ArmFailSafe) and then one that does (RevokeCommissioning)
+        try:
+            await self.default_controller.TestOnlySendCommandTimedRequestFlagWithNoTimedInvoke(nodeid=self.dut_node_id, endpoint=0, payload=cmd)
+            asserts.fail("Unexpected success response from sending an Invoke with TimedRequest flag and no timed interaction")
+        except InteractionModelError as e:
+            asserts.assert_equal(e.status, Status.UnsupportedAccess,
+                                 "Unexpected error response from Invoke with TimedRequest flag and no TimedInvoke")
+
+        # Try with RevokeCommissioning
+        # First open a commissioning window for us to revoke, so we know this command is able to succeed absent this error
+        _ = self.default_controller.OpenCommissioningWindow(
+            nodeid=self.dut_node_id, timeout=600, iteration=10000, discriminator=1234, option=1)
+        cmd = FakeRevokeCommissioning()
+        try:
+            await self.default_controller.TestOnlySendCommandTimedRequestFlagWithNoTimedInvoke(nodeid=self.dut_node_id, endpoint=0, payload=cmd)
+            asserts.fail("Unexpected success response from sending an Invoke with TimedRequest flag and no timed interaction")
+        except InteractionModelError as e:
+            asserts.assert_equal(e.status, Status.UnsupportedAccess,
+                                 "Unexpected error response from Invoke with TimedRequest flag and no TimedInvoke")
+
+        self.print_step(9, "Send invoke for a command that requires timedRequest, but doesn't use one")
+        # RevokeCommissioning requires a timed interaction. This is enforced in the python layer because
+        # the generated class indicates that a timed interaction is required. The fake class overrides this.
+        try:
+            await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=0, payload=cmd)
+            asserts.fail("Incorrectly received a success response for a command that required TimedInvoke action")
+        except InteractionModelError as e:
+            asserts.assert_equal(e.status, Status.NeedsTimedInteraction)
+
+        # Cleanup - actually revoke commissioning to close the open window
+        await self.default_controller.SendCommand(nodeid=self.dut_node_id, endpoint=0, payload=Clusters.AdministratorCommissioning.Commands.RevokeCommissioning(), timedRequestTimeoutMs=6000)
+
+
+if __name__ == "__main__":
+    default_matter_test_main()