Testing framework: Add function to generate PICS from device attributes (#40623)

* Add pics generation function

* Unit test for new function

* Remove TODO. I did add unit tests. TO-DONE, baby

* appease the bot

* Restyled by isort

* appease the linter

* mypy

* Update src/python_testing/matter_testing_infrastructure/matter/testing/pics.py

Co-authored-by: Arkadiusz Bokowy <arkadiusz.bokowy@gmail.com>

* Address review comments

* Don't run TestPics in the CI

---------

Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: Arkadiusz Bokowy <arkadiusz.bokowy@gmail.com>
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 74d2fe5..7ed64c9 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -851,6 +851,7 @@
                   scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestDefaultWarnings.py'
                   scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestIdChecks.py'
                   scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestMatterTestingSupport.py'
+                  scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestPics.py'
                   scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestSpecParsingDeviceType.py'
                   scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestSpecParsingNamespace.py'
                   scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestSpecParsingSelection.py'
diff --git a/src/python_testing/TestPics.py b/src/python_testing/TestPics.py
new file mode 100644
index 0000000..f6ca597
--- /dev/null
+++ b/src/python_testing/TestPics.py
@@ -0,0 +1,127 @@
+#
+#    Copyright (c) 2025 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.
+#
+
+from mobly import asserts
+
+import matter.clusters as Clusters
+from matter.clusters.Attribute import AsyncReadTransaction
+from matter.testing.matter_testing import MatterBaseTest, default_matter_test_main
+from matter.testing.pics import generate_device_element_pics_from_device_wildcard
+from matter.testing.spec_parsing import PrebuiltDataModelDirectory, build_xml_clusters
+
+
+class TestPicsHelpers(MatterBaseTest):
+    def test_pics_generation(self):
+        xml_cluster, _ = build_xml_clusters(PrebuiltDataModelDirectory.k1_4_1)
+        # 0: opcreds with an attribute, an accepted command, a generated command
+        # 0: cadmin with a feature, an attribute, an accepted command
+        # 1: Door lock cluster with two features, two attributes, two accepted commands, two generated commands
+        wildcard = AsyncReadTransaction.ReadResponse(attributes={}, events=[], tlvAttributes={})
+        desc = Clusters.Descriptor
+        opcreds = Clusters.OperationalCredentials
+        cadmin = Clusters.AdministratorCommissioning
+        lock = Clusters.DoorLock
+
+        wildcard.tlvAttributes[0] = {desc.id: {}, opcreds.id: {}, cadmin.id: {}}
+        wildcard.tlvAttributes[1] = {lock.id: {}}
+
+        wildcard.tlvAttributes[0][desc.id][desc.Attributes.AttributeList.attribute_id] = [
+            desc.Attributes.DeviceTypeList.attribute_id]
+        wildcard.tlvAttributes[0][desc.id][desc.Attributes.AcceptedCommandList.attribute_id] = []
+        wildcard.tlvAttributes[0][desc.id][desc.Attributes.GeneratedCommandList.attribute_id] = []
+        wildcard.tlvAttributes[0][desc.id][desc.Attributes.FeatureMap.attribute_id] = 0
+        desc_expected = ['DESC.S', 'DESC.S.A0000']
+        # The rest of the processing is done through tlvAttributes, but this one needs to be parsed, so it comes from here.
+        wildcard.attributes = {0: {desc: {desc.Attributes.DeviceTypeList: [
+            desc.Structs.DeviceTypeStruct(deviceType=0x16, revision=1)]}}}
+
+        wildcard.tlvAttributes[0][opcreds.id][opcreds.Attributes.AttributeList.attribute_id] = [
+            opcreds.Attributes.NOCs.attribute_id]
+        wildcard.tlvAttributes[0][opcreds.id][opcreds.Attributes.AcceptedCommandList.attribute_id] = [
+            opcreds.Commands.AttestationRequest.command_id, opcreds.Commands.AddTrustedRootCertificate.command_id]
+        wildcard.tlvAttributes[0][opcreds.id][opcreds.Attributes.GeneratedCommandList.attribute_id] = [
+            opcreds.Commands.AttestationResponse.command_id]
+        wildcard.tlvAttributes[0][opcreds.id][opcreds.Attributes.FeatureMap.attribute_id] = 0
+        opcreds_expected_suffix = ['A0000', 'C00.Rsp', 'C0b.Rsp', 'C01.Tx']
+        opcreds_expected = [f'OPCREDS.S.{s}' for s in opcreds_expected_suffix]
+        opcreds_expected.append('OPCREDS.S')
+
+        wildcard.tlvAttributes[0][cadmin.id][cadmin.Attributes.AttributeList.attribute_id] = [
+            cadmin.Attributes.AdminFabricIndex.attribute_id]
+        wildcard.tlvAttributes[0][cadmin.id][cadmin.Attributes.AcceptedCommandList.attribute_id] = [
+            cadmin.Commands.OpenCommissioningWindow.command_id]
+        wildcard.tlvAttributes[0][cadmin.id][cadmin.Attributes.GeneratedCommandList.attribute_id] = []
+        wildcard.tlvAttributes[0][cadmin.id][cadmin.Attributes.FeatureMap.attribute_id] = 1
+        cadmin_expected_suffix = ['A0001', 'C00.Rsp', 'F00']
+        cadmin_expected = [f'CADMIN.S.{s}' for s in cadmin_expected_suffix]
+        cadmin_expected.append('CADMIN.S')
+
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.AttributeList.attribute_id] = [
+            lock.Attributes.DoorState.attribute_id, lock.Attributes.LockType.attribute_id]
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.AcceptedCommandList.attribute_id] = [
+            lock.Commands.GetUser.command_id, lock.Commands.GetCredentialStatus.command_id]
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.GeneratedCommandList.attribute_id] = [
+            lock.Commands.GetUserResponse.command_id, lock.Commands.GetCredentialStatusResponse.command_id]
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.FeatureMap.attribute_id] = 3
+        lock_expected_suffix = ['A0003', 'A0001', 'C1b.Rsp', 'C24.Rsp', 'C1c.Tx', 'C25.Tx', 'F00', 'F01']
+        lock_expected = [f'DRLK.S.{s}' for s in lock_expected_suffix]
+        lock_expected.append('DRLK.S')
+
+        def check_expected_pics(pics_list: dict[int, list[str]]):
+            asserts.assert_equal(set(pics_list.keys()), set([0, 1]), "Unexpected endpoints in PICS list")
+            asserts.assert_equal(set(pics_list[0]), set(desc_expected + opcreds_expected + cadmin_expected +
+                                 ['IDM.S', 'MCORE.ROLE.COMMISSIONEE']), "Incorrect PICS list on EP0")
+            asserts.assert_equal(set(pics_list[1]), set(lock_expected + ['IDM.S']), "Incorrect PICS list on EP1")
+
+        pics_list, problems = generate_device_element_pics_from_device_wildcard(wildcard, xml_cluster)
+        asserts.assert_equal(len(problems), 0, "Unexpected problems found generating PICS list")
+        check_expected_pics(pics_list)
+
+        # Add globals, should be no errors, should not appear
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.AttributeList.attribute_id].extend(
+            [lock.Attributes.AttributeList.attribute_id, lock.Attributes.AcceptedCommandList.attribute_id, lock.Attributes.GeneratedCommandList.attribute_id, lock.Attributes.FeatureMap.attribute_id, lock.Attributes.ClusterRevision.attribute_id])
+
+        pics_list, problems = generate_device_element_pics_from_device_wildcard(wildcard, xml_cluster)
+        asserts.assert_equal(len(problems), 0, "Unexpected problems found generating PICS list")
+        check_expected_pics(pics_list)
+
+        # Add MEI cluster, MEI attribute, MEI accepted command, MEI generated command - should be no errors, should not appear
+        unit_testing = Clusters.UnitTesting
+        wildcard.tlvAttributes[1][unit_testing.id] = {}
+        # MEI cluster
+        wildcard.tlvAttributes[1][unit_testing.id][unit_testing.Attributes.AttributeList.attribute_id] = [
+            unit_testing.Attributes.Bitmap16.attribute_id]
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.AttributeList.attribute_id].append(0x60060000)
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.AcceptedCommandList.attribute_id].append(0x60060000)
+        wildcard.tlvAttributes[1][lock.id][lock.Attributes.GeneratedCommandList.attribute_id].append(0x60060001)
+
+        pics_list, problems = generate_device_element_pics_from_device_wildcard(wildcard, xml_cluster)
+        asserts.assert_equal(len(problems), 0, "Unexpected problems found generating PICS list")
+        check_expected_pics(pics_list)
+
+        # Add a standard cluster that's not part of the standard cluster set - this should cause an error because we don't know the PICS
+        unknown_standard_cluster = max(xml_cluster.keys()) + 1
+        wildcard.tlvAttributes[1][unknown_standard_cluster] = {}
+        wildcard.tlvAttributes[1][unknown_standard_cluster][unit_testing.Attributes.AttributeList.attribute_id] = [0]
+
+        pics_list, problems = generate_device_element_pics_from_device_wildcard(wildcard, xml_cluster)
+        asserts.assert_equal(len(problems), 1, "Unexpected problems found generating PICS list")
+        check_expected_pics(pics_list)
+
+
+if __name__ == "__main__":
+    default_matter_test_main()
diff --git a/src/python_testing/matter_testing_infrastructure/matter/testing/pics.py b/src/python_testing/matter_testing_infrastructure/matter/testing/pics.py
index 54a975f..eadbf17 100644
--- a/src/python_testing/matter_testing_infrastructure/matter/testing/pics.py
+++ b/src/python_testing/matter_testing_infrastructure/matter/testing/pics.py
@@ -20,6 +20,14 @@
 import typing
 import xml.etree.ElementTree as ET
 
+import matter.clusters as Clusters
+from matter.clusters.Attribute import AsyncReadTransaction
+from matter.testing.global_attribute_ids import (AttributeIdType, GlobalAttributeIds, attribute_id_type, is_standard_cluster_id,
+                                                 is_standard_command_id)
+from matter.testing.problem_notices import ClusterPathLocation, ProblemNotice, ProblemSeverity
+from matter.testing.spec_parsing import XmlCluster
+from matter.tlv import uint
+
 
 def attribute_pics_str(pics_base: str, id: int) -> str:
     return f'{pics_base}.S.A{id:04x}'
@@ -102,3 +110,51 @@
         with open(path, 'r') as f:
             lines = f.readlines()
             return parse_pics(lines)
+
+
+def generate_device_element_pics_from_device_wildcard(wildcard: AsyncReadTransaction.ReadResponse, xml_clusters: dict[uint, XmlCluster]) -> tuple[dict[int, list[str]], list[ProblemNotice]]:
+    ''' Returns a list of device element PICS and problems from each device wildcard.
+    '''
+    # Endpoint to list of device element PICS
+    device_pics: dict[int, list[str]] = {}
+    problems = []
+    for endpoint_id, endpoint in wildcard.tlvAttributes.items():
+        endpoint_has_server = False
+        device_pics[endpoint_id] = []
+        for cluster_id, cluster in endpoint.items():
+            if not is_standard_cluster_id(cluster_id):
+                continue
+            if cluster_id not in xml_clusters:
+                # This is covered by another test - we don't want to block every test, so just warn here
+                location = ClusterPathLocation(endpoint_id=endpoint_id, cluster_id=cluster_id)
+                problems.append(ProblemNotice(test_name="General error", location=location,
+                                severity=ProblemSeverity.WARNING, problem="Unknown standard cluster on device"))
+                continue
+            cluster_pics = xml_clusters[cluster_id].pics
+            device_pics[endpoint_id].append(server_pics_str(cluster_pics))
+            endpoint_has_server = True
+            for attribute_id in cluster[GlobalAttributeIds.ATTRIBUTE_LIST_ID]:
+                if attribute_id_type(attribute_id) != AttributeIdType.kStandardNonGlobal:
+                    continue
+                device_pics[endpoint_id].append(attribute_pics_str(cluster_pics, attribute_id))
+            feature_map = cluster[GlobalAttributeIds.FEATURE_MAP_ID]
+            for i in range(0, 16):
+                bit = 1 << i
+                if feature_map & bit:
+                    device_pics[endpoint_id].append(feature_pics_str(cluster_pics, i))
+            for cmd_id in cluster[GlobalAttributeIds.ACCEPTED_COMMAND_LIST_ID]:
+                if not is_standard_command_id(cmd_id):
+                    continue
+                device_pics[endpoint_id].append(accepted_cmd_pics_str(cluster_pics, cmd_id))
+            for cmd_id in cluster.get(GlobalAttributeIds.GENERATED_COMMAND_LIST_ID, []):
+                if not is_standard_command_id(cmd_id):
+                    continue
+                device_pics[endpoint_id].append(generated_cmd_pics_str(cluster_pics, cmd_id))
+        if endpoint_has_server:
+            device_pics[endpoint_id].append('IDM.S')
+    ep0_device_type_list = wildcard.attributes.get(0, {}).get(
+        Clusters.Descriptor, {}).get(Clusters.Descriptor.Attributes.DeviceTypeList, [])
+    if any(d.deviceType == 0x16 for d in ep0_device_type_list):
+        device_pics.setdefault(0, []).append('MCORE.ROLE.COMMISSIONEE')
+
+    return device_pics, problems
diff --git a/src/python_testing/test_metadata.yaml b/src/python_testing/test_metadata.yaml
index 134fd1b..3c55b3a 100644
--- a/src/python_testing/test_metadata.yaml
+++ b/src/python_testing/test_metadata.yaml
@@ -85,6 +85,8 @@
       reason: Unit test - does not run against an app
     - name: TestConformanceTest.py
       reason: Unit test - does not run against an app
+    - name: TestPics.py
+      reason: Unit test - does not run against an app
     - name: TestSpecParsingSelection.py
       reason: Unit test - does not run against an app
     - name: TestMatterTestingSupport.py