diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b8e5afb551d559..78c4070da9ee5b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -448,6 +448,7 @@ jobs: 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 87940a900ce991..5effaaa13c6479 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 5282f10daf39a0..5daceaf05a088a 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 @@ CHIP_ERROR CommandSender::AllocateBuffer() return CHIP_NO_ERROR; } -CHIP_ERROR CommandSender::SendCommandRequest(const SessionHandle & session, Optional timeout) +CHIP_ERROR CommandSender::SendCommandRequestInternal(const SessionHandle & session, Optional timeout) { VerifyOrReturnError(mState == State::AddedCommand, CHIP_ERROR_INCORRECT_STATE); @@ -76,15 +77,6 @@ CHIP_ERROR CommandSender::SendCommandRequest(const SessionHandle & session, Opti 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 @@ CHIP_ERROR CommandSender::SendCommandRequest(const SessionHandle & session, Opti return SendInvokeRequest(); } +#if CONFIG_BUILD_FOR_HOST_UNIT_TEST +CHIP_ERROR CommandSender::TestOnlyCommandSenderTimedRequestFlagWithNoTimedInvoke(const SessionHandle & session, + Optional timeout) +{ + VerifyOrReturnError(mTimedRequest, CHIP_ERROR_INCORRECT_STATE); + return SendCommandRequestInternal(session, timeout); +} +#endif + +CHIP_ERROR CommandSender::SendCommandRequest(const SessionHandle & session, Optional 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 c61043c6b33265..6da9b053856c82 100644 --- a/src/app/CommandSender.h +++ b/src/app/CommandSender.h @@ -121,7 +121,8 @@ class CommandSender final : public Messaging::ExchangeDelegate * 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 @@ class CommandSender final : public Messaging::ExchangeDelegate */ template CHIP_ERROR AddRequestDataNoTimedCheck(const CommandPathParams & aCommandPath, const CommandDataT & aData, - const Optional & aTimedInvokeTimeoutMs, bool aSuppressResponse = false) + const Optional & 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 timeout = NullOptional); + #endif // CONFIG_BUILD_FOR_HOST_UNIT_TEST private: @@ -265,6 +273,8 @@ class CommandSender final : public Messaging::ExchangeDelegate CHIP_ERROR Finalize(System::PacketBufferHandle & commandPacket); + CHIP_ERROR SendCommandRequestInternal(const SessionHandle & session, Optional 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 1d3e2eb735e116..846dd6ab3c33ea 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 @@ class InteractionModelCommands chip::app::CommandPathParams commandPath = { endpointId, clusterId, commandId, (chip::app::CommandPathFlags::kEndpointIdValid) }; - auto commandSender = std::make_unique(mCallback, device->GetExchangeManager(), - mTimedInteractionTimeoutMs.HasValue()); + auto commandSender = std::make_unique( + 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 39d4fa58647ce3..e69ad2d2850d39 100644 --- a/src/controller/python/ChipDeviceController-ScriptBinding.cpp +++ b/src/controller/python/ChipDeviceController-ScriptBinding.cpp @@ -139,7 +139,7 @@ PyChipError pychip_DeviceController_SetThreadOperationalDataset(const char * thr 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_CloseSession(chip::Controller::DeviceCommiss } 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 1022182a9d94cd..37437eac71886c 100644 --- a/src/controller/python/chip/ChipDeviceCtrl.py +++ b/src/controller/python/chip/ChipDeviceCtrl.py @@ -473,13 +473,13 @@ def EstablishPASESessionBLE(self, setupPinCode: int, discriminator: int, nodeid: 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 @@ def ComputeRoundTripTimeout(self, nodeid, upperLayerProcessingTimeoutMs: int = 0 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 @@ async def SendCommand(self, nodeid: int, endpoint: int, payload: ClusterObjects. 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 @@ def _InitLib(self): 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 203c92aeb49744..af1336541b2e3e 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 @@ def _OnCommandSenderDoneCallback(closure): 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 @@ def SendCommand(future: Future, eventLoop, responseType: Type, device, commandPa 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 @@ def Init(): 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 468bff52d5f13d..c824c251995932 100644 --- a/src/controller/python/chip/clusters/command.cpp +++ b/src/controller/python/chip/clusters/command.cpp @@ -36,7 +36,11 @@ extern "C" { 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 @@ void pychip_CommandSender_InitCallbacks(OnCommandSenderResponseCallback onComman 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 callback = std::make_unique(appContext); - std::unique_ptr sender = std::make_unique(callback.get(), device->GetExchangeManager(), - /* is timed request */ timedRequestTimeoutMs != 0); + std::unique_ptr sender = + std::make_unique(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 @@ PyChipError pychip_CommandSender_SendCommand(void * appContext, DeviceProxy * de 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 callback = std::make_unique(appContext); + std::unique_ptr sender = std::make_unique(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::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 00000000000000..7adb7eb11108fa --- /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()