diff --git a/0036-Python-Avoid-InvalidStateError-on-cancel-35380.patch b/0036-Python-Avoid-InvalidStateError-on-cancel-35380.patch new file mode 100644 index 0000000..75a2b16 --- /dev/null +++ b/0036-Python-Avoid-InvalidStateError-on-cancel-35380.patch @@ -0,0 +1,32 @@ +From 3f6ba15267c08c8665edafd27043de6a6a3c60ee Mon Sep 17 00:00:00 2001 +From: Stefan Agner +Date: Wed, 4 Sep 2024 14:49:53 +0200 +Subject: [PATCH] [Python] Avoid InvalidStateError on cancel (#35380) + +When the co-routine GetConnectedDevice() gets cancelled, the wait_for +call will cancel the future we are waiting for. However, the SDK still +calls the _DeviceAvailableCallback with an error (CHIP Error 0x00000074: +The operation has been cancelled). However, we can't set the future +result at this point as the co-routine is already cancelled. + +Simply check the future state before setting the result. +--- + src/controller/python/chip/ChipDeviceCtrl.py | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py +index 3ea996e53c..bb4119f1bc 100644 +--- a/src/controller/python/chip/ChipDeviceCtrl.py ++++ b/src/controller/python/chip/ChipDeviceCtrl.py +@@ -854,6 +854,8 @@ class ChipDeviceControllerBase(): + self._future = future + + def _deviceAvailable(self): ++ if self._future.cancelled(): ++ return + if self._returnDevice.value is not None: + self._future.set_result(self._returnDevice) + else: +-- +2.46.0 + diff --git a/0037-python-Add-direct-attribute-paths-to-Read-34833.patch b/0037-python-Add-direct-attribute-paths-to-Read-34833.patch new file mode 100644 index 0000000..3cb22b6 --- /dev/null +++ b/0037-python-Add-direct-attribute-paths-to-Read-34833.patch @@ -0,0 +1,99 @@ +From d586d15f83c5fb5fff30b7b7a91b0aced387aa5f Mon Sep 17 00:00:00 2001 +From: C Freeman +Date: Wed, 7 Aug 2024 23:05:32 -0400 +Subject: [PATCH] python: Add direct attribute paths to Read (#34833) + +* python: Add direct attribute paths to Read + +Supports one particular use case: read one or all endpoints, +all clusters, specific (global) attribute. See spec 8.9.2.4. This +is an allowed wildcard construct that is not currently expressable +in the API. + +Test: Used on wildcard read for matter_testing_support. This is + therefore tested on any test using that decorator - switch + and timesync. + +* Restyled by isort + +--------- + +Co-authored-by: Restyled.io +--- + src/controller/python/chip/ChipDeviceCtrl.py | 18 +++++++++++++++--- + src/python_testing/matter_testing_support.py | 1 + + 2 files changed, 16 insertions(+), 3 deletions(-) + +diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py +index bb4119f1bc..e337bb0c5d 100644 +--- a/src/controller/python/chip/ChipDeviceCtrl.py ++++ b/src/controller/python/chip/ChipDeviceCtrl.py +@@ -1142,8 +1142,12 @@ class ChipDeviceControllerBase(): + # Wildcard attribute id + typing.Tuple[int, typing.Type[ClusterObjects.Cluster]], + # Concrete path +- typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]] ++ typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]], ++ # Directly specified attribute path ++ ClusterAttribute.AttributePath + ]): ++ if isinstance(pathTuple, ClusterAttribute.AttributePath): ++ return pathTuple + if pathTuple == ('*') or pathTuple == (): + # Wildcard + return ClusterAttribute.AttributePath() +@@ -1228,7 +1232,9 @@ class ChipDeviceControllerBase(): + # Wildcard attribute id + typing.Tuple[int, typing.Type[ClusterObjects.Cluster]], + # Concrete path +- typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]] ++ typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]], ++ # Directly specified attribute path ++ ClusterAttribute.AttributePath + ]] = None, + dataVersionFilters: typing.List[typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int]] = None, events: typing.List[ + typing.Union[ +@@ -1266,6 +1272,8 @@ class ChipDeviceControllerBase(): + ReadAttribute(1, [ Clusters.BasicInformation ] ) -- case 5 above. + ReadAttribute(1, [ (1, Clusters.BasicInformation.Attributes.Location ] ) -- case 1 above. + ++ An AttributePath can also be specified directly by [chip.cluster.Attribute.AttributePath(...)] ++ + dataVersionFilters: A list of tuples of (endpoint, cluster, data version). + + events: A list of tuples of varying types depending on the type of read being requested: +@@ -1326,7 +1334,9 @@ class ChipDeviceControllerBase(): + # Wildcard attribute id + typing.Tuple[int, typing.Type[ClusterObjects.Cluster]], + # Concrete path +- typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]] ++ typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]], ++ # Directly specified attribute path ++ ClusterAttribute.AttributePath + ]], dataVersionFilters: typing.List[typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int]] = None, + returnClusterObject: bool = False, + reportInterval: typing.Tuple[int, int] = None, +@@ -1350,6 +1360,8 @@ class ChipDeviceControllerBase(): + ReadAttribute(1, [ Clusters.BasicInformation ] ) -- case 5 above. + ReadAttribute(1, [ (1, Clusters.BasicInformation.Attributes.Location ] ) -- case 1 above. + ++ An AttributePath can also be specified directly by [chip.cluster.Attribute.AttributePath(...)] ++ + returnClusterObject: This returns the data as consolidated cluster objects, with all attributes for a cluster inside + a single cluster-wide cluster object. + +diff --git a/src/python_testing/matter_testing_support.py b/src/python_testing/matter_testing_support.py +index a3bd5d2837..93a89192dc 100644 +--- a/src/python_testing/matter_testing_support.py ++++ b/src/python_testing/matter_testing_support.py +@@ -53,6 +53,7 @@ import chip.logging + import chip.native + from chip import discovery + from chip.ChipStack import ChipStack ++from chip.clusters import Attribute + from chip.clusters import ClusterObjects as ClusterObjects + from chip.clusters.Attribute import EventReadResult, SubscriptionTransaction + from chip.exceptions import ChipStackError +-- +2.46.0 + diff --git a/0038-Python-Process-attribute-cache-updates-in-Python-thr.patch b/0038-Python-Process-attribute-cache-updates-in-Python-thr.patch new file mode 100644 index 0000000..89a954e --- /dev/null +++ b/0038-Python-Process-attribute-cache-updates-in-Python-thr.patch @@ -0,0 +1,659 @@ +From 6da341868cdd64ac9064395a9bde6e5954e56579 Mon Sep 17 00:00:00 2001 +From: Stefan Agner +Date: Tue, 17 Sep 2024 15:51:16 +0200 +Subject: [PATCH] [Python] Process attribute cache updates in Python thread + (#35557) + +* [Python] Process attribute cache updates in Python thread + +Instead of processing the attribute update in the SDK thread, process +them on request in the Python thread. This avoids acks being sent back +too late to the device after the last DataReport if there are many +attribute updates sent at once. + +Currently still the same data model and processing is done. There is +certainly also room for optimization to make this more efficient. + +* Get updated attribute values + +Make sure to get the attribute values again after each command to get +the updated attribute cache. + +* Reference ReadEvent/ReadAttribute APIs on dev controller object +--- + .../repl/Matter_Basic_Interactions.ipynb | 4 +- + src/controller/python/chip/ChipDeviceCtrl.py | 130 ++++++------ + .../python/chip/clusters/Attribute.py | 76 ++++--- + .../test/test_scripts/cluster_objects.py | 9 +- + src/python_testing/matter_testing_support.py | 185 ++++++++++++++++++ + 5 files changed, 302 insertions(+), 102 deletions(-) + +diff --git a/docs/guides/repl/Matter_Basic_Interactions.ipynb b/docs/guides/repl/Matter_Basic_Interactions.ipynb +index 41c1c78865..bc021aec73 100644 +--- a/docs/guides/repl/Matter_Basic_Interactions.ipynb ++++ b/docs/guides/repl/Matter_Basic_Interactions.ipynb +@@ -3504,7 +3504,7 @@ + "source": [ + "#### Read Events:\n", + "\n", +- "A `ReadEvents` API exists that behaves similarly to the `ReadAttributes` API. It permits the same degrees of wildcard expression as its counterpart and follows the same format for expressing all wildcard permutations." ++ "A `ReadEvent` API exists that behaves similarly to the `ReadAttribute` API. It permits the same degrees of wildcard expression as its counterpart and follows the same format for expressing all wildcard permutations." + ] + }, + { +@@ -3609,7 +3609,7 @@ + "source": [ + "### Subscription Interaction\n", + "\n", +- "To subscribe to a Node, the same `ReadAttributes` API is used to trigger a subscription, with a valid `reportInterval` tuple passed in being used as a way to indicate the request to create a subscription." ++ "To subscribe to a Node, the same `ReadAttribute` API is used to trigger a subscription, with a valid `reportInterval` tuple passed in being used as a way to indicate the request to create a subscription." + ] + }, + { +diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py +index e337bb0c5d..3892580a55 100644 +--- a/src/controller/python/chip/ChipDeviceCtrl.py ++++ b/src/controller/python/chip/ChipDeviceCtrl.py +@@ -1222,22 +1222,25 @@ class ChipDeviceControllerBase(): + else: + raise ValueError("Unsupported Attribute Path") + +- async def Read(self, nodeid: int, attributes: typing.List[typing.Union[ +- None, # Empty tuple, all wildcard +- typing.Tuple[int], # Endpoint +- # Wildcard endpoint, Cluster id present +- typing.Tuple[typing.Type[ClusterObjects.Cluster]], +- # Wildcard endpoint, Cluster + Attribute present +- typing.Tuple[typing.Type[ClusterObjects.ClusterAttributeDescriptor]], +- # Wildcard attribute id +- typing.Tuple[int, typing.Type[ClusterObjects.Cluster]], +- # Concrete path +- typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]], +- # Directly specified attribute path +- ClusterAttribute.AttributePath +- ]] = None, ++ async def Read( ++ self, ++ nodeid: int, ++ attributes: typing.Optional[typing.List[typing.Union[ ++ None, # Empty tuple, all wildcard ++ typing.Tuple[int], # Endpoint ++ # Wildcard endpoint, Cluster id present ++ typing.Tuple[typing.Type[ClusterObjects.Cluster]], ++ # Wildcard endpoint, Cluster + Attribute present ++ typing.Tuple[typing.Type[ClusterObjects.ClusterAttributeDescriptor]], ++ # Wildcard attribute id ++ typing.Tuple[int, typing.Type[ClusterObjects.Cluster]], ++ # Concrete path ++ typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]], ++ # Directly specified attribute path ++ ClusterAttribute.AttributePath ++ ]]] = None, + dataVersionFilters: typing.List[typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int]] = None, events: typing.List[ +- typing.Union[ ++ typing.Union[ + None, # Empty tuple, all wildcard + typing.Tuple[str, int], # all wildcard with urgency set + typing.Tuple[int, int], # Endpoint, +@@ -1249,10 +1252,11 @@ class ChipDeviceControllerBase(): + typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int], + # Concrete path + typing.Tuple[int, typing.Type[ClusterObjects.ClusterEvent], int] +- ]] = None, +- eventNumberFilter: typing.Optional[int] = None, +- returnClusterObject: bool = False, reportInterval: typing.Tuple[int, int] = None, +- fabricFiltered: bool = True, keepSubscriptions: bool = False, autoResubscribe: bool = True): ++ ]] = None, ++ eventNumberFilter: typing.Optional[int] = None, ++ returnClusterObject: bool = False, reportInterval: typing.Optional[typing.Tuple[int, int]] = None, ++ fabricFiltered: bool = True, keepSubscriptions: bool = False, autoResubscribe: bool = True ++ ): + ''' + Read a list of attributes and/or events from a target node + +@@ -1315,32 +1319,42 @@ class ChipDeviceControllerBase(): + eventPaths = [self._parseEventPathTuple( + v) for v in events] if events else None + +- ClusterAttribute.Read(future=future, eventLoop=eventLoop, device=device.deviceProxy, devCtrl=self, ++ transaction = ClusterAttribute.AsyncReadTransaction(future, eventLoop, self, returnClusterObject) ++ ClusterAttribute.Read(transaction, device=device.deviceProxy, + attributes=attributePaths, dataVersionFilters=clusterDataVersionFilters, events=eventPaths, +- eventNumberFilter=eventNumberFilter, returnClusterObject=returnClusterObject, ++ eventNumberFilter=eventNumberFilter, + subscriptionParameters=ClusterAttribute.SubscriptionParameters( + reportInterval[0], reportInterval[1]) if reportInterval else None, + fabricFiltered=fabricFiltered, + keepSubscriptions=keepSubscriptions, autoResubscribe=autoResubscribe).raise_on_error() +- return await future ++ await future + +- async def ReadAttribute(self, nodeid: int, attributes: typing.List[typing.Union[ +- None, # Empty tuple, all wildcard +- typing.Tuple[int], # Endpoint +- # Wildcard endpoint, Cluster id present +- typing.Tuple[typing.Type[ClusterObjects.Cluster]], +- # Wildcard endpoint, Cluster + Attribute present +- typing.Tuple[typing.Type[ClusterObjects.ClusterAttributeDescriptor]], +- # Wildcard attribute id +- typing.Tuple[int, typing.Type[ClusterObjects.Cluster]], +- # Concrete path +- typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]], +- # Directly specified attribute path +- ClusterAttribute.AttributePath +- ]], dataVersionFilters: typing.List[typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int]] = None, +- returnClusterObject: bool = False, +- reportInterval: typing.Tuple[int, int] = None, +- fabricFiltered: bool = True, keepSubscriptions: bool = False, autoResubscribe: bool = True): ++ if result := transaction.GetSubscriptionHandler(): ++ return result ++ else: ++ return transaction.GetReadResponse() ++ ++ async def ReadAttribute( ++ self, ++ nodeid: int, ++ attributes: typing.Optional[typing.List[typing.Union[ ++ None, # Empty tuple, all wildcard ++ typing.Tuple[int], # Endpoint ++ # Wildcard endpoint, Cluster id present ++ typing.Tuple[typing.Type[ClusterObjects.Cluster]], ++ # Wildcard endpoint, Cluster + Attribute present ++ typing.Tuple[typing.Type[ClusterObjects.ClusterAttributeDescriptor]], ++ # Wildcard attribute id ++ typing.Tuple[int, typing.Type[ClusterObjects.Cluster]], ++ # Concrete path ++ typing.Tuple[int, typing.Type[ClusterObjects.ClusterAttributeDescriptor]], ++ # Directly specified attribute path ++ ClusterAttribute.AttributePath ++ ]]], dataVersionFilters: typing.List[typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int]] = None, ++ returnClusterObject: bool = False, ++ reportInterval: typing.Optional[typing.Tuple[int, int]] = None, ++ fabricFiltered: bool = True, keepSubscriptions: bool = False, autoResubscribe: bool = True ++ ): + ''' + Read a list of attributes from a target node, this is a wrapper of DeviceController.Read() + +@@ -1401,23 +1415,27 @@ class ChipDeviceControllerBase(): + else: + return res.attributes + +- async def ReadEvent(self, nodeid: int, events: typing.List[typing.Union[ +- None, # Empty tuple, all wildcard +- typing.Tuple[str, int], # all wildcard with urgency set +- typing.Tuple[int, int], # Endpoint, +- # Wildcard endpoint, Cluster id present +- typing.Tuple[typing.Type[ClusterObjects.Cluster], int], +- # Wildcard endpoint, Cluster + Event present +- typing.Tuple[typing.Type[ClusterObjects.ClusterEvent], int], +- # Wildcard event id +- typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int], +- # Concrete path +- typing.Tuple[int, typing.Type[ClusterObjects.ClusterEvent], int] +- ]], eventNumberFilter: typing.Optional[int] = None, +- fabricFiltered: bool = True, +- reportInterval: typing.Tuple[int, int] = None, +- keepSubscriptions: bool = False, +- autoResubscribe: bool = True): ++ async def ReadEvent( ++ self, ++ nodeid: int, ++ events: typing.List[typing.Union[ ++ None, # Empty tuple, all wildcard ++ typing.Tuple[str, int], # all wildcard with urgency set ++ typing.Tuple[int, int], # Endpoint, ++ # Wildcard endpoint, Cluster id present ++ typing.Tuple[typing.Type[ClusterObjects.Cluster], int], ++ # Wildcard endpoint, Cluster + Event present ++ typing.Tuple[typing.Type[ClusterObjects.ClusterEvent], int], ++ # Wildcard event id ++ typing.Tuple[int, typing.Type[ClusterObjects.Cluster], int], ++ # Concrete path ++ typing.Tuple[int, typing.Type[ClusterObjects.ClusterEvent], int] ++ ]], eventNumberFilter: typing.Optional[int] = None, ++ fabricFiltered: bool = True, ++ reportInterval: typing.Optional[typing.Tuple[int, int]] = None, ++ keepSubscriptions: bool = False, ++ autoResubscribe: bool = True ++ ): + ''' + Read a list of events from a target node, this is a wrapper of DeviceController.Read() + +diff --git a/src/controller/python/chip/clusters/Attribute.py b/src/controller/python/chip/clusters/Attribute.py +index 63ca02d8df..fcd7d62187 100644 +--- a/src/controller/python/chip/clusters/Attribute.py ++++ b/src/controller/python/chip/clusters/Attribute.py +@@ -327,14 +327,17 @@ class AttributeCache: + returnClusterObject: bool = False + attributeTLVCache: Dict[int, Dict[int, Dict[int, bytes]]] = field( + default_factory=lambda: {}) +- attributeCache: Dict[int, List[Cluster]] = field( +- default_factory=lambda: {}) + versionList: Dict[int, Dict[int, Dict[int, int]]] = field( + default_factory=lambda: {}) + ++ _attributeCacheUpdateNeeded: set[AttributePath] = field( ++ default_factory=lambda: set()) ++ _attributeCache: Dict[int, List[Cluster]] = field( ++ default_factory=lambda: {}) ++ + def UpdateTLV(self, path: AttributePath, dataVersion: int, data: Union[bytes, ValueDecodeFailure]): + ''' Store data in TLV since that makes it easiest to eventually convert to either the +- cluster or attribute view representations (see below in UpdateCachedData). ++ cluster or attribute view representations (see below in GetUpdatedAttributeCache()). + ''' + if (path.EndpointId not in self.attributeTLVCache): + self.attributeTLVCache[path.EndpointId] = {} +@@ -357,7 +360,10 @@ class AttributeCache: + + clusterCache[path.AttributeId] = data + +- def UpdateCachedData(self, changedPathSet: set[AttributePath]): ++ # For this path the attribute cache still requires an update. ++ self._attributeCacheUpdateNeeded.add(path) ++ ++ def GetUpdatedAttributeCache(self) -> Dict[int, List[Cluster]]: + ''' This converts the raw TLV data into a cluster object format. + + Two formats are available: +@@ -394,12 +400,12 @@ class AttributeCache: + except Exception as ex: + return ValueDecodeFailure(value, ex) + +- for attributePath in changedPathSet: ++ for attributePath in self._attributeCacheUpdateNeeded: + endpointId, clusterId, attributeId = attributePath.EndpointId, attributePath.ClusterId, attributePath.AttributeId + +- if endpointId not in self.attributeCache: +- self.attributeCache[endpointId] = {} +- endpointCache = self.attributeCache[endpointId] ++ if endpointId not in self._attributeCache: ++ self._attributeCache[endpointId] = {} ++ endpointCache = self._attributeCache[endpointId] + + if clusterId not in _ClusterIndex: + # +@@ -427,6 +433,8 @@ class AttributeCache: + + attributeType = _AttributeIndex[(clusterId, attributeId)][0] + clusterCache[attributeType] = handle_attribute_view(endpointId, clusterId, attributeId, attributeType) ++ self._attributeCacheUpdateNeeded.clear() ++ return self._attributeCache + + + class SubscriptionTransaction: +@@ -447,12 +455,12 @@ class SubscriptionTransaction: + def GetAttributes(self): + ''' Returns the attribute value cache tracking the latest state on the publisher. + ''' +- return self._readTransaction._cache.attributeCache ++ return self._readTransaction._cache.GetUpdatedAttributeCache() + + def GetAttribute(self, path: TypedAttributePath) -> Any: + ''' Returns a specific attribute given a TypedAttributePath. + ''' +- data = self._readTransaction._cache.attributeCache ++ data = self._readTransaction._cache.GetUpdatedAttributeCache() + + if (self._readTransaction._cache.returnClusterObject): + return eval(f'data[path.Path.EndpointId][path.ClusterType].{path.AttributeName}') +@@ -685,6 +693,18 @@ class AsyncReadTransaction: + def GetAllEventValues(self): + return self._events + ++ def GetReadResponse(self) -> AsyncReadTransaction.ReadResponse: ++ """Prepares and returns the ReadResponse object.""" ++ return self.ReadResponse( ++ attributes=self._cache.GetUpdatedAttributeCache(), ++ events=self._events, ++ tlvAttributes=self._cache.attributeTLVCache ++ ) ++ ++ def GetSubscriptionHandler(self) -> SubscriptionTransaction | None: ++ """Returns subscription transaction.""" ++ return self._subscription_handler ++ + def handleAttributeData(self, path: AttributePath, dataVersion: int, status: int, data: bytes): + try: + imStatus = chip.interaction_model.Status(status) +@@ -751,7 +771,7 @@ class AsyncReadTransaction: + if not self._future.done(): + self._subscription_handler = SubscriptionTransaction( + self, subscriptionId, self._devCtrl) +- self._future.set_result(self._subscription_handler) ++ self._future.set_result(self) + else: + self._subscription_handler._subscriptionId = subscriptionId + if self._subscription_handler._onResubscriptionSucceededCb is not None: +@@ -780,8 +800,6 @@ class AsyncReadTransaction: + pass + + def _handleReportEnd(self): +- self._cache.UpdateCachedData(self._changedPathSet) +- + if (self._subscription_handler is not None): + for change in self._changedPathSet: + if self._subscription_handler.OnAttributeChangeCb: +@@ -812,8 +830,7 @@ class AsyncReadTransaction: + if self._resultError is not None: + self._future.set_exception(self._resultError.to_exception()) + else: +- self._future.set_result(AsyncReadTransaction.ReadResponse( +- attributes=self._cache.attributeCache, events=self._events, tlvAttributes=self._cache.attributeTLVCache)) ++ self._future.set_result(self) + + # + # Decrement the ref on ourselves to match the increment that happened at allocation. +@@ -1041,18 +1058,16 @@ _ReadParams = construct.Struct( + ) + + +-def Read(future: Future, eventLoop, device, devCtrl, +- attributes: List[AttributePath] = None, dataVersionFilters: List[DataVersionFilter] = None, +- events: List[EventPath] = None, eventNumberFilter: Optional[int] = None, returnClusterObject: bool = True, +- subscriptionParameters: SubscriptionParameters = None, ++def Read(transaction: AsyncReadTransaction, device, ++ attributes: Optional[List[AttributePath]] = None, dataVersionFilters: Optional[List[DataVersionFilter]] = None, ++ events: Optional[List[EventPath]] = None, eventNumberFilter: Optional[int] = None, ++ subscriptionParameters: Optional[SubscriptionParameters] = None, + fabricFiltered: bool = True, keepSubscriptions: bool = False, autoResubscribe: bool = True) -> PyChipError: + if (not attributes) and dataVersionFilters: + raise ValueError( + "Must provide valid attribute list when data version filters is not null") + + handle = chip.native.GetLibraryHandle() +- transaction = AsyncReadTransaction( +- future, eventLoop, devCtrl, returnClusterObject) + + attributePathsForCffi = None + if attributes is not None: +@@ -1159,25 +1174,6 @@ def Read(future: Future, eventLoop, device, devCtrl, + return res + + +-def ReadAttributes(future: Future, eventLoop, device, devCtrl, +- attributes: List[AttributePath], dataVersionFilters: List[DataVersionFilter] = None, +- returnClusterObject: bool = True, +- subscriptionParameters: SubscriptionParameters = None, fabricFiltered: bool = True) -> int: +- return Read(future=future, eventLoop=eventLoop, device=device, +- devCtrl=devCtrl, attributes=attributes, dataVersionFilters=dataVersionFilters, +- events=None, returnClusterObject=returnClusterObject, +- subscriptionParameters=subscriptionParameters, fabricFiltered=fabricFiltered) +- +- +-def ReadEvents(future: Future, eventLoop, device, devCtrl, +- events: List[EventPath], eventNumberFilter=None, returnClusterObject: bool = True, +- subscriptionParameters: SubscriptionParameters = None, fabricFiltered: bool = True) -> int: +- return Read(future=future, eventLoop=eventLoop, device=device, devCtrl=devCtrl, attributes=None, +- dataVersionFilters=None, events=events, eventNumberFilter=eventNumberFilter, +- returnClusterObject=returnClusterObject, +- subscriptionParameters=subscriptionParameters, fabricFiltered=fabricFiltered) +- +- + def Init(): + handle = chip.native.GetLibraryHandle() + +diff --git a/src/controller/python/test/test_scripts/cluster_objects.py b/src/controller/python/test/test_scripts/cluster_objects.py +index 37f6819cbe..45c9e99576 100644 +--- a/src/controller/python/test/test_scripts/cluster_objects.py ++++ b/src/controller/python/test/test_scripts/cluster_objects.py +@@ -214,12 +214,12 @@ class ClusterObjectTests: + sub.SetAttributeUpdateCallback(subUpdate) + + try: +- data = sub.GetAttributes() + req = Clusters.OnOff.Commands.On() + await devCtrl.SendCommand(nodeid=NODE_ID, endpoint=1, payload=req) + + await asyncio.wait_for(event.wait(), timeout=11) + ++ data = sub.GetAttributes() + if (data[1][Clusters.OnOff][Clusters.OnOff.Attributes.OnOff] != 1): + raise ValueError("Current On/Off state should be 1") + +@@ -230,6 +230,7 @@ class ClusterObjectTests: + + await asyncio.wait_for(event.wait(), timeout=11) + ++ data = sub.GetAttributes() + if (data[1][Clusters.OnOff][Clusters.OnOff.Attributes.OnOff] != 0): + raise ValueError("Current On/Off state should be 0") + +@@ -252,13 +253,12 @@ class ClusterObjectTests: + sub.SetAttributeUpdateCallback(subUpdate) + + try: +- data = sub.GetAttributes() +- + req = Clusters.OnOff.Commands.On() + await devCtrl.SendCommand(nodeid=NODE_ID, endpoint=1, payload=req) + + await asyncio.wait_for(event.wait(), timeout=11) + ++ data = sub.GetAttributes() + cluster: Clusters.OnOff = data[1][Clusters.OnOff] + if (not cluster.onOff): + raise ValueError("Current On/Off state should be True") +@@ -270,6 +270,7 @@ class ClusterObjectTests: + + await asyncio.wait_for(event.wait(), timeout=11) + ++ data = sub.GetAttributes() + cluster: Clusters.OnOff = data[1][Clusters.OnOff] + if (cluster.onOff): + raise ValueError("Current On/Off state should be False") +@@ -296,7 +297,6 @@ class ClusterObjectTests: + logger.info("Test Subscription With MinInterval of 0") + sub = await devCtrl.ReadAttribute(nodeid=NODE_ID, + attributes=[Clusters.OnOff, Clusters.LevelControl], reportInterval=(0, 60)) +- data = sub.GetAttributes() + + logger.info("Sending off command") + +@@ -313,6 +313,7 @@ class ClusterObjectTests: + + logger.info("Checking read back value is indeed 254") + ++ data = sub.GetAttributes() + if (data[1][Clusters.LevelControl][Clusters.LevelControl.Attributes.CurrentLevel] != 254): + raise ValueError("Current Level should have been 254") + +diff --git a/src/python_testing/matter_testing_support.py b/src/python_testing/matter_testing_support.py +index 93a89192dc..6a7b3c4a32 100644 +--- a/src/python_testing/matter_testing_support.py ++++ b/src/python_testing/matter_testing_support.py +@@ -1550,6 +1550,191 @@ def async_test_body(body): + return async_runner + + ++def per_node_test(body): ++ """ Decorator to be used for PICS-free tests that apply to the entire node. ++ ++ Use this decorator when your script needs to be run once to validate the whole node. ++ To use this decorator, the test must NOT have an associated pics_ method. ++ """ ++ ++ def whole_node_runner(self: MatterBaseTest, *args, **kwargs): ++ asserts.assert_false(self.get_test_pics(self.current_test_info.name), "pics_ method supplied for per_node_test.") ++ return _async_runner(body, self, *args, **kwargs) ++ ++ return whole_node_runner ++ ++ ++EndpointCheckFunction = typing.Callable[[Clusters.Attribute.AsyncReadTransaction.ReadResponse, int], bool] ++ ++ ++def _has_cluster(wildcard, endpoint, cluster: ClusterObjects.Cluster) -> bool: ++ try: ++ return cluster in wildcard.attributes[endpoint] ++ except KeyError: ++ return False ++ ++ ++def has_cluster(cluster: ClusterObjects.ClusterObjectDescriptor) -> EndpointCheckFunction: ++ """ EndpointCheckFunction that can be passed as a parameter to the per_endpoint_test decorator. ++ ++ Use this function with the per_endpoint_test decorator to run this test on all endpoints with ++ the specified cluster. For example, given a device with the following conformance ++ ++ EP0: cluster A, B, C ++ EP1: cluster D, E ++ EP2, cluster D ++ EP3, cluster E ++ ++ And the following test specification: ++ @per_endpoint_test(has_cluster(Clusters.D)) ++ test_mytest(self): ++ ... ++ ++ The test would be run on endpoint 1 and on endpoint 2. ++ ++ If the cluster is not found on any endpoint the decorator will call the on_skip function to ++ notify the test harness that the test is not applicable to this node and the test will not be run. ++ """ ++ return partial(_has_cluster, cluster=cluster) ++ ++ ++def _has_attribute(wildcard, endpoint, attribute: ClusterObjects.ClusterAttributeDescriptor) -> bool: ++ cluster = getattr(Clusters, attribute.__qualname__.split('.')[-3]) ++ try: ++ attr_list = wildcard.attributes[endpoint][cluster][cluster.Attributes.AttributeList] ++ return attribute.attribute_id in attr_list ++ except KeyError: ++ return False ++ ++ ++def has_attribute(attribute: ClusterObjects.ClusterAttributeDescriptor) -> EndpointCheckFunction: ++ """ EndpointCheckFunction that can be passed as a parameter to the per_endpoint_test decorator. ++ ++ Use this function with the per_endpoint_test decorator to run this test on all endpoints with ++ the specified attribute. For example, given a device with the following conformance ++ ++ EP0: cluster A, B, C ++ EP1: cluster D with attribute d, E ++ EP2, cluster D with attribute d ++ EP3, cluster D without attribute d ++ ++ And the following test specification: ++ @per_endpoint_test(has_attribute(Clusters.D.Attributes.d)) ++ test_mytest(self): ++ ... ++ ++ The test would be run on endpoint 1 and on endpoint 2. ++ ++ If the cluster is not found on any endpoint the decorator will call the on_skip function to ++ notify the test harness that the test is not applicable to this node and the test will not be run. ++ """ ++ return partial(_has_attribute, attribute=attribute) ++ ++ ++def _has_feature(wildcard, endpoint, cluster: ClusterObjects.ClusterObjectDescriptor, feature: IntFlag) -> bool: ++ try: ++ feature_map = wildcard.attributes[endpoint][cluster][cluster.Attributes.FeatureMap] ++ return (feature & feature_map) != 0 ++ except KeyError: ++ return False ++ ++ ++def has_feature(cluster: ClusterObjects.ClusterObjectDescriptor, feature: IntFlag) -> EndpointCheckFunction: ++ """ EndpointCheckFunction that can be passed as a parameter to the per_endpoint_test decorator. ++ ++ Use this function with the per_endpoint_test decorator to run this test on all endpoints with ++ the specified feature. For example, given a device with the following conformance ++ ++ EP0: cluster A, B, C ++ EP1: cluster D with feature F0 ++ EP2, cluster D with feature F0 ++ EP3, cluster D without feature F0 ++ ++ And the following test specification: ++ @per_endpoint_test(has_feature(Clusters.D.Bitmaps.Feature.F0)) ++ test_mytest(self): ++ ... ++ ++ The test would be run on endpoint 1 and on endpoint 2. ++ ++ If the cluster is not found on any endpoint the decorator will call the on_skip function to ++ notify the test harness that the test is not applicable to this node and the test will not be run. ++ """ ++ return partial(_has_feature, cluster=cluster, feature=feature) ++ ++ ++async def get_accepted_endpoints_for_test(self: MatterBaseTest, accept_function: EndpointCheckFunction) -> list[uint]: ++ """ Helper function for the per_endpoint_test decorator. ++ ++ Returns a list of endpoints on which the test should be run given the accept_function for the test. ++ """ ++ wildcard = await self.default_controller.Read(self.dut_node_id, [(Clusters.Descriptor), Attribute.AttributePath(None, None, GlobalAttributeIds.ATTRIBUTE_LIST_ID), Attribute.AttributePath(None, None, GlobalAttributeIds.FEATURE_MAP_ID), Attribute.AttributePath(None, None, GlobalAttributeIds.ACCEPTED_COMMAND_LIST_ID)]) ++ return [e for e in wildcard.attributes.keys() if accept_function(wildcard, e)] ++ ++ ++def per_endpoint_test(accept_function: EndpointCheckFunction): ++ """ Test decorator for a test that needs to be run once per endpoint that meets the accept_function criteria. ++ ++ Place this decorator above the test_ method to have the test framework run this test once per endpoint. ++ This decorator takes an EndpointCheckFunction to assess whether a test needs to be run on a particular ++ endpoint. ++ ++ For example, given the following device conformance: ++ ++ EP0: cluster A, B, C ++ EP1: cluster D, E ++ EP2, cluster D ++ EP3, cluster E ++ ++ And the following test specification: ++ @per_endpoint_test(has_cluster(Clusters.D)) ++ test_mytest(self): ++ ... ++ ++ The test would be run on endpoint 1 and on endpoint 2. ++ ++ If the cluster is not found on any endpoint the decorator will call the on_skip function to ++ notify the test harness that the test is not applicable to this node and the test will not be run. ++ ++ The decorator works by setting the self.matter_test_config.endpoint value and running the test function. ++ Therefore, tests that make use of this decorator should call controller functions against that endpoint. ++ Support functions in this file default to this endpoint. ++ ++ Tests that use this decorator cannot use a pics_ method for test selection and should not reference any ++ PICS values internally. ++ """ ++ def per_endpoint_test_internal(body): ++ def per_endpoint_runner(self: MatterBaseTest, *args, **kwargs): ++ asserts.assert_false(self.get_test_pics(self.current_test_info.name), "pics_ method supplied for per_endpoint_test.") ++ runner_with_timeout = asyncio.wait_for(get_accepted_endpoints_for_test(self, accept_function), timeout=30) ++ endpoints = asyncio.run(runner_with_timeout) ++ if not endpoints: ++ logging.info("No matching endpoints found - skipping test") ++ asserts.skip('No endpoints match requirements') ++ return ++ logging.info(f"Running test on the following endpoints: {endpoints}") ++ # setup_class is meant to be called once, but setup_test is expected to be run before ++ # each iteration. Mobly will run it for us the first time, but since we're running this ++ # more than one time, we want to make sure we reset everything as expected. ++ # Ditto for teardown - we want to tear down after each iteration, and we want to notify the hook that ++ # the test iteration is stopped. test_stop is called by on_pass or on_fail during the last iteration or ++ # on failure. ++ original_ep = self.matter_test_config.endpoint ++ for e in endpoints: ++ logging.info(f'Running test on endpoint {e}') ++ if e != endpoints[0]: ++ self.setup_test() ++ self.matter_test_config.endpoint = e ++ _async_runner(body, self, *args, **kwargs) ++ if e != endpoints[-1] and not self.failed: ++ self.teardown_test() ++ test_duration = (datetime.now(timezone.utc) - self.test_start_time) / timedelta(microseconds=1) ++ self.runner_hook.test_stop(exception=None, duration=test_duration) ++ self.matter_test_config.endpoint = original_ep ++ return per_endpoint_runner ++ return per_endpoint_test_internal ++ ++ + class CommissionDeviceTest(MatterBaseTest): + """Test class auto-injected at the start of test list to commission a device when requested""" + +-- +2.46.0 +