Update PICS Generator to 1.4 (#35619)

* Updated PICS Generator to match 1.4 PICS

* Add script to validate PICS and cluster macthing

* Updated based on review feedback

* Apply suggestions from code review

Co-authored-by: C Freeman <cecille@google.com>
Co-authored-by: Andrei Litvin <andy314@gmail.com>

* Add XMLPICSValidator to wordlist

* Restyled by prettier-markdown

* Restyled by isort

---------

Co-authored-by: C Freeman <cecille@google.com>
Co-authored-by: Andrei Litvin <andy314@gmail.com>
Co-authored-by: Restyled.io <commits@restyled.io>
diff --git a/.github/.wordlist.txt b/.github/.wordlist.txt
index dc56ae9..5c24cde 100644
--- a/.github/.wordlist.txt
+++ b/.github/.wordlist.txt
@@ -1603,6 +1603,7 @@
 xFFFF
 xfffff
 xFFFFFFEFFFFFFFFF
+XMLPICSValidator
 xtensa
 xvzf
 xwayland
diff --git a/src/tools/PICS-generator/PICSGenerator.py b/src/tools/PICS-generator/PICSGenerator.py
index 4fd5103..acdeb67 100644
--- a/src/tools/PICS-generator/PICSGenerator.py
+++ b/src/tools/PICS-generator/PICSGenerator.py
@@ -1,5 +1,5 @@
 #
-#    Copyright (c) 2023 Project CHIP Authors
+#    Copyright (c) 2023-2024 Project CHIP Authors
 #    All rights reserved.
 #
 #    Licensed under the Apache License, Version 2.0 (the "License");
@@ -22,6 +22,7 @@
 import xml.etree.ElementTree as ET
 
 import chip.clusters as Clusters
+from pics_generator_support import map_cluster_name_to_pics_xml, pics_xml_file_list_loader
 from rich.console import Console
 
 # Add the path to python_testing folder, in order to be able to import from matter_testing_support
@@ -40,41 +41,11 @@
 
     console.print(f"Handling PICS for {clusterName}")
 
-    # Map clusters to common XML template if needed
-    if "ICDManagement" == clusterName:
-        clusterName = "ICD Management"
-
-    elif "OTA Software Update Provider" in clusterName or "OTA Software Update Requestor" in clusterName:
-        clusterName = "OTA Software Update"
-
-    elif "On/Off" == clusterName:
-        clusterName = clusterName.replace("/", "-")
-
-    elif "GroupKeyManagement" == clusterName:
-        clusterName = "Group Communication"
-
-    elif "Wake On LAN" == clusterName or "Low Power" == clusterName:
-        clusterName = "Media Cluster"
-
-    elif "Operational Credentials" == clusterName:
-        clusterName = "Node Operational Credentials"
-
-    elif "Laundry Washer Controls" == clusterName:
-        clusterName = "Washer Controls"
-
-    # Workaround for naming colisions with current logic
-    elif "Thermostat" == clusterName:
-        clusterName = "Thermostat Cluster"
-
-    elif "Boolean State" == clusterName:
-        clusterName = "Boolean State Cluster"
-
-    if "AccessControl" in clusterName:
-        clusterName = "Access Control cluster"
+    picsFileName = map_cluster_name_to_pics_xml(clusterName, xmlFileList)
 
     # Determine if file has already been handled and use this file
     for outputFolderFileName in os.listdir(outputPathStr):
-        if clusterName in outputFolderFileName:
+        if picsFileName in outputFolderFileName:
             xmlPath = outputPathStr
             fileName = outputFolderFileName
             break
@@ -82,7 +53,7 @@
     # If no file is found in output folder, determine if there is a match for the cluster name in input folder
     if fileName == "":
         for file in xmlFileList:
-            if file.lower().startswith(clusterName.lower()):
+            if file.lower().startswith(picsFileName.lower()):
                 fileName = file
                 break
         else:
@@ -420,10 +391,10 @@
 
 # Load PICS XML templates
 print("Capture list of PICS XML templates")
-xmlFileList = os.listdir(xmlTemplatePathStr)
+xmlFileList = pics_xml_file_list_loader(xmlTemplatePathStr, True)
 
 # Setup output path
-print(outputPathStr)
+print(f"Output path: {outputPathStr}")
 
 outputPath = pathlib.Path(outputPathStr)
 if not outputPath.exists():
diff --git a/src/tools/PICS-generator/README.md b/src/tools/PICS-generator/README.md
index 9c20616..312d790 100644
--- a/src/tools/PICS-generator/README.md
+++ b/src/tools/PICS-generator/README.md
@@ -59,6 +59,13 @@
 python3 PICSGenerator.py --pics-template <pathToPicsTemplateFolder> --pics-output <outputPath> --commissioning-method ble-thread --discriminator <DESCRIMINATOR> --passcode <PASSCODE> --thread-dataset-hex <DATASET_AS_HEX>
 ```
 
+or in case the device is e.g. an example running on a Linux/macOS system, use
+the on-network commissioning:
+
+```
+python3 PICSGenerator.py --pics-template <pathToPicsTemplateFolder> --pics-output <outputPath> --commissioning-method on-network --discriminator <DESCRIMINATOR> --passcode <PASSCODE>
+```
+
 In case the device uses a development PAA, the following parameter should be
 added.
 
@@ -78,3 +85,20 @@
 ```
 python3 PICSGenerator.py --pics-template <pathToPicsTemplateFolder> --pics-output <outputPath>
 ```
+
+# Updates for future releases
+
+Given each new release adds PICS files, to ensure the tool is able to map the
+cluster names to the PICS XML files, the XMLPICSValidator script can be used to
+validate the mapping and will inform in case a cluster can not be mapped to a
+PICS XML file.
+
+The purpose of this script is mainly to make the update of this tool to future
+versions of Matter easier and is not intended as a script for generating the
+PICS.
+
+To run the XMLPICSValidator, the following command can be used:
+
+```
+python3 XMLPICSValidator.py --pics-template <pathToPicsTemplateFolder>
+```
diff --git a/src/tools/PICS-generator/XMLPICSValidator.py b/src/tools/PICS-generator/XMLPICSValidator.py
new file mode 100644
index 0000000..d728a61
--- /dev/null
+++ b/src/tools/PICS-generator/XMLPICSValidator.py
@@ -0,0 +1,47 @@
+#
+#    Copyright (c) 2024 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 argparse
+import os
+import sys
+
+from pics_generator_support import map_cluster_name_to_pics_xml, pics_xml_file_list_loader
+
+# Add the path to python_testing folder, in order to be able to import from matter_testing_support
+sys.path.append(os.path.abspath(sys.path[0] + "/../../python_testing"))
+from spec_parsing_support import build_xml_clusters  # noqa: E402
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--pics-template', required=True)
+args, unknown = parser.parse_known_args()
+
+xml_template_path_str = args.pics_template
+
+print("Build list of PICS XML")
+pics_xml_file_list = pics_xml_file_list_loader(xml_template_path_str, True)
+
+print("Build list of spec XML")
+xml_clusters, problems = build_xml_clusters()
+
+for cluster in xml_clusters:
+    pics_xml_file_name = map_cluster_name_to_pics_xml(xml_clusters[cluster].name, pics_xml_file_list)
+
+    if pics_xml_file_name:
+        print(f"{xml_clusters[cluster].name} - {pics_xml_file_name} ✅")
+    else:
+        print(
+            f"Could not find matching PICS XML file for {xml_clusters[cluster].name} - {xml_clusters[cluster].pics} (Provisional: {xml_clusters[cluster].is_provisional}) ❌")
diff --git a/src/tools/PICS-generator/pics_generator_support.py b/src/tools/PICS-generator/pics_generator_support.py
new file mode 100644
index 0000000..53e0248
--- /dev/null
+++ b/src/tools/PICS-generator/pics_generator_support.py
@@ -0,0 +1,74 @@
+#
+#    Copyright (c) 2024 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 os
+
+cluster_to_pics_dict = {
+    # Name mapping due to inconsistent naming of PICS files
+    "ICDManagement": "ICD Management",
+    "OTA Software Update Provider": "OTA Software Update",
+    "OTA Software Update Requestor": "OTA Software Update",
+    "On/Off": "On-Off",
+    "GroupKeyManagement": "Group Communication",
+    "Wake on LAN": "Media Cluster",
+    "Low Power": "Media Cluster",
+    "Keypad Input": "Media Cluster",
+    "Audio Output": "Media Cluster",
+    "Media Input": "Media Cluster",
+    "Target Navigator": "Media Cluster",
+    "Content Control": "Media Cluster",
+    "Channel": "Media Cluster",
+    "Media Playback": "Media Cluster",
+    "Account Login": "Media Cluster",
+    "Application Basic": "Media Cluster",
+    "Content Launcher": "Media Cluster",
+    "Content App Observer": "Media Cluster",
+    "Application Launch": "Media Cluster",
+    "Operational Credentials": "Node Operational Credentials",
+
+    # Workaround for naming colisions with current logic
+    "Thermostat": "Thermostat Cluster",
+    "Boolean State": "Boolean State Cluster",
+    "AccessControl": "Access Control Cluster",
+}
+
+
+def pics_xml_file_list_loader(pics_xml_path: str, log_loaded_pics_files: bool) -> list:
+
+    pics_xml_file_list = os.listdir(pics_xml_path)
+
+    if log_loaded_pics_files:
+        if not pics_xml_path.endswith('/'):
+            pics_xml_path += '/'
+
+        for pics_xml_file in pics_xml_file_list:
+            print(f"{pics_xml_path}/{pics_xml_file}")
+
+    return pics_xml_file_list
+
+
+def map_cluster_name_to_pics_xml(cluster_name, pics_xml_file_list) -> str:
+    file_name = ""
+
+    pics_file_name = cluster_to_pics_dict.get(cluster_name, cluster_name)
+
+    for file in pics_xml_file_list:
+        if file.lower().startswith(pics_file_name.lower()):
+            file_name = file
+            break
+
+    return file_name