diff --git a/script/refresh_stubs.py b/script/refresh_stubs.py index 12316b6..a2220c1 100644 --- a/script/refresh_stubs.py +++ b/script/refresh_stubs.py @@ -10,6 +10,7 @@ ("module-definition-test", "test", "de-DE"), ("xknx_test_project", "test", None), ("test_project-ets4", "test", "de-DE"), + ("testprojekt-ets6-functions", None, "de-DE"), ] for file_name, password, language in file_names: diff --git a/test/resources/stubs/module-definition-test.json b/test/resources/stubs/module-definition-test.json index 1766bbe..b2c7ff9 100644 --- a/test/resources/stubs/module-definition-test.json +++ b/test/resources/stubs/module-definition-test.json @@ -8,7 +8,7 @@ "created_by": "ETS5", "schema_version": "20", "tool_version": "5.7.1428.39779", - "xknxproject_version": "3.0.0", + "xknxproject_version": "3.2.0", "language_code": "de-DE" }, "communication_objects": { @@ -388,4 +388,4 @@ } }, "functions": {} -} +} \ No newline at end of file diff --git a/test/resources/stubs/test_project-ets4.json b/test/resources/stubs/test_project-ets4.json index 8aa9edd..66427a4 100644 --- a/test/resources/stubs/test_project-ets4.json +++ b/test/resources/stubs/test_project-ets4.json @@ -8,7 +8,7 @@ "created_by": "ETS4", "schema_version": "11", "tool_version": "ETS 4.2.0 (Build 3884)", - "xknxproject_version": "3.1.1", + "xknxproject_version": "3.2.0", "language_code": "de-DE" }, "communication_objects": { @@ -313,4 +313,4 @@ } }, "functions": {} -} +} \ No newline at end of file diff --git a/test/resources/stubs/testprojekt-ets6-functions.json b/test/resources/stubs/testprojekt-ets6-functions.json index 865247c..9242990 100644 --- a/test/resources/stubs/testprojekt-ets6-functions.json +++ b/test/resources/stubs/testprojekt-ets6-functions.json @@ -1,131 +1,131 @@ { - "info": { - "project_id": "P-05C0", - "name": "Minimal-Example", - "last_modified": "2023-07-07T12:41:11.4132414Z", - "group_address_style": "ThreeLevel", - "guid": "efabc0f9-4e81-440d-a236-b80913e85730", - "created_by": "ETS6", - "schema_version": "22", - "tool_version": "6.1.5686.0", - "xknxproject_version": "3.2.0", - "language_code": "de-DE" + "info": { + "project_id": "P-05C0", + "name": "Minimal-Example", + "last_modified": "2023-07-07T12:41:11.4132414Z", + "group_address_style": "ThreeLevel", + "guid": "efabc0f9-4e81-440d-a236-b80913e85730", + "created_by": "ETS6", + "schema_version": "22", + "tool_version": "6.1.5686.0", + "xknxproject_version": "3.2.0", + "language_code": "de-DE" + }, + "communication_objects": {}, + "topology": { + "0": { + "name": "", + "description": null, + "lines": { + "0": { + "name": "", + "description": null, + "devices": [], + "medium_type": "KNXnet/IP (IP)" + } + } }, - "communication_objects": {}, - "topology": { + "1": { + "name": "", + "description": null, + "lines": { "0": { - "name": "", - "description": null, - "lines": { - "0": { - "name": "", - "description": null, - "devices": [], - "medium_type": "KNXnet/IP (IP)" - } - } + "name": "", + "description": null, + "devices": [], + "medium_type": "KNXnet/IP (IP)" }, "1": { - "name": "", - "description": null, - "lines": { - "0": { - "name": "", - "description": null, - "devices": [], - "medium_type": "KNXnet/IP (IP)" - }, - "1": { - "name": "", - "description": null, - "devices": [], - "medium_type": "Twisted Pair (TP)" - } - } + "name": "", + "description": null, + "devices": [], + "medium_type": "Twisted Pair (TP)" } + } + } + }, + "devices": {}, + "group_addresses": { + "0/0/1": { + "name": "Schalten", + "identifier": "GA-1", + "raw_address": 1, + "address": "0/0/1", + "project_uid": 14, + "dpt": { + "main": 1, + "sub": 1 + }, + "communication_object_ids": [], + "description": "Livingroom LivingroomLight" }, - "devices": {}, - "group_addresses": { + "0/0/2": { + "name": "Status", + "identifier": "GA-2", + "raw_address": 2, + "address": "0/0/2", + "project_uid": 16, + "dpt": { + "main": 1, + "sub": 1 + }, + "communication_object_ids": [], + "description": "Livingroom LivingroomLight" + } + }, + "locations": { + "Minimal-Example": { + "type": "Building", + "identifier": "P-05C0-0_BP-1", + "name": "Minimal-Example", + "usage_id": null, + "usage_text": "", + "number": "", + "description": "", + "project_uid": 9, + "devices": [], + "spaces": { + "Livingroom": { + "type": "Room", + "identifier": "P-05C0-0_BP-2", + "name": "Livingroom", + "usage_id": "SU-4", + "usage_text": "Wohnzimmer", + "number": "", + "description": "", + "project_uid": 10, + "devices": [], + "spaces": {}, + "functions": [ + "F-1" + ] + } + }, + "functions": [] + } + }, + "functions": { + "F-1": { + "function_type": "FT-1", + "group_addresses": { "0/0/1": { - "name": "Schalten", - "identifier": "GA-1", - "raw_address": 1, - "address": "0/0/1", - "project_uid": 14, - "dpt": { - "main": 1, - "sub": 1 - }, - "communication_object_ids": [], - "description": "Livingroom LivingroomLight" + "address": "0/0/1", + "name": "", + "project_uid": 15, + "role": "SwitchOnOff" }, "0/0/2": { - "name": "Status", - "identifier": "GA-2", - "raw_address": 2, - "address": "0/0/2", - "project_uid": 16, - "dpt": { - "main": 1, - "sub": 1 - }, - "communication_object_ids": [], - "description": "Livingroom LivingroomLight" - } - }, - "locations": { - "Minimal-Example": { - "type": "Building", - "identifier": "P-05C0-0_BP-1", - "name": "Minimal-Example", - "usage_id": null, - "usage_text": "", - "number": "", - "description": "", - "project_uid": 9, - "devices": [], - "spaces": { - "Livingroom": { - "type": "Room", - "identifier": "P-05C0-0_BP-2", - "name": "Livingroom", - "usage_id": "SU-4", - "usage_text": "Wohnzimmer", - "number": "", - "description": "", - "project_uid": 10, - "devices": [], - "spaces": {}, - "functions": [ - "F-1" - ] - } - }, - "functions": [] - } - }, - "functions": { - "F-1": { - "function_type": "FT-1", - "group_addresses": { - "0/0/1": { - "address": "0/0/1", - "name": "", - "project_uid": 15, - "role": "SwitchOnOff" - }, - "0/0/2": { - "address": "0/0/2", - "name": "", - "project_uid": 17, - "role": "InfoOnOff" - } - }, - "identifier": "F-1", - "name": "LivingroomLight", - "project_uid": 11, - "space_id": "P-05C0-0_BP-2", - "usage_text": "switchable light" + "address": "0/0/2", + "name": "", + "project_uid": 17, + "role": "InfoOnOff" } + }, + "identifier": "F-1", + "name": "LivingroomLight", + "project_uid": 11, + "space_id": "P-05C0-0_BP-2", + "usage_text": "Licht schalten" } -} + } +} \ No newline at end of file diff --git a/test/resources/stubs/xknx_test_project.json b/test/resources/stubs/xknx_test_project.json index 2127ae0..2ff8471 100644 --- a/test/resources/stubs/xknx_test_project.json +++ b/test/resources/stubs/xknx_test_project.json @@ -8,7 +8,7 @@ "created_by": "ETS5", "schema_version": "20", "tool_version": "5.7.1428.39779", - "xknxproject_version": "3.0.0", + "xknxproject_version": "3.2.0", "language_code": null }, "communication_objects": { @@ -674,4 +674,4 @@ } }, "functions": {} -} +} \ No newline at end of file diff --git a/xknxproject/loader/knx_master_loader.py b/xknxproject/loader/knx_master_loader.py index e2a344f..0d88061 100644 --- a/xknxproject/loader/knx_master_loader.py +++ b/xknxproject/loader/knx_master_loader.py @@ -6,6 +6,7 @@ from zipfile import Path from xknxproject.const import ETS4_PRODUCT_LANGUAGES +from xknxproject.models import KNXMasterData, TranslationsType from xknxproject.zip import KNXProjContents _LOGGER = logging.getLogger("xknxproject.log") @@ -19,12 +20,13 @@ def load( knx_proj_contents: KNXProjContents, knx_master_file: Path, language: str | None, - ) -> tuple[dict[str, str], dict[str, str], str | None, dict[str, str]]: - """Load KNX master data.""" + ) -> tuple[KNXMasterData, str | None]: + """Load KNX master data. Returns KNXMasterData and the found language code.""" manufacturer_mapping: dict[str, str] = {} space_usage_mapping: dict[str, str] = {} product_languages: list[str] = [] # eg. "en-US", "de-DE", "fr-FR" language_code: str | None = None + translations: TranslationsType = {} function_type_mapping: dict[str, str] = {} with knx_master_file.open(mode="rb") as master_xml: @@ -64,21 +66,22 @@ def load( f"/{{*}}Language[@Identifier='{language_code}']" "/{*}TranslationUnit/{*}TranslationElement" ): - _ref_id = translation_element.get("RefId") - if _ref_id not in space_usage_mapping: - continue - if ( - translation_node := translation_element.find( - "{*}Translation[@AttributeName='Text']" - ) - ) is not None: - space_usage_mapping[_ref_id] = translation_node.get("Text", "") + _ref_id = translation_element.get("RefId", "") + translations[_ref_id] = { + attr: text + for item in translation_element.findall("{*}Translation") + if (attr := item.get("AttributeName")) is not None + and (text := item.get("Text")) is not None + } return ( - manufacturer_mapping, - space_usage_mapping, + KNXMasterData( + function_type_names=function_type_mapping, + manufacturer_names=manufacturer_mapping, + space_usage_mapping=space_usage_mapping, + translations=translations, + ), language_code, - function_type_mapping, ) @staticmethod diff --git a/xknxproject/loader/project_loader.py b/xknxproject/loader/project_loader.py index 273ed27..9aa7455 100644 --- a/xknxproject/loader/project_loader.py +++ b/xknxproject/loader/project_loader.py @@ -7,6 +7,7 @@ from xknxproject.models import ( ComObjectInstanceRef, DeviceInstance, + KNXMasterData, SpaceType, XMLArea, XMLFunction, @@ -26,8 +27,7 @@ class ProjectLoader: @staticmethod def load( knx_proj_contents: KNXProjContents, - space_usage_names: dict[str, str], - function_type_names: dict[str, str], + knx_master_data: KNXMasterData, ) -> tuple[ list[XMLGroupAddress], list[XMLArea], @@ -68,8 +68,8 @@ def load( location_loader = _LocationLoader( knx_proj_contents, + knx_master_data, devices, - space_usage_names, ) for location_element in tree.findall( f"{{*}}Project/{{*}}Installations/{{*}}Installation/{{*}}{element_name}" @@ -86,7 +86,7 @@ def load( for function in functions: function.usage_text = ( - function_type_names.get(function.function_type, "") + knx_master_data.get_function_type_name(function.function_type) if function.function_type else "" ) @@ -258,17 +258,17 @@ class _LocationLoader: def __init__( self, knx_proj_contents: KNXProjContents, + knx_master_data: KNXMasterData, devices: list[DeviceInstance], - space_usage_names: dict[str, str], ): """Initialize the LocationLoader.""" + self.knx_master_data = knx_master_data self._element_name = ( "BuildingPart" if knx_proj_contents.is_ets4_project() else "Space" ) self.devices: dict[str, str] = { device.identifier: device.individual_address for device in devices } - self.space_usage_names = space_usage_names def load( self, location_element: ElementTree.Element, functions: list[XMLFunction] @@ -284,7 +284,9 @@ def parse_space( ) -> XMLSpace: """Parse a space from the document.""" usage_id = node.get("Usage") - usage_text = self.space_usage_names.get(usage_id, "") if usage_id else "" + usage_text = ( + self.knx_master_data.get_space_usage_name(usage_id) if usage_id else "" + ) project_uid = node.get("Puid") space: XMLSpace = XMLSpace( identifier=node.get("Id"), # type: ignore[arg-type] diff --git a/xknxproject/models/__init__.py b/xknxproject/models/__init__.py index 36fd951..a3a98c1 100644 --- a/xknxproject/models/__init__.py +++ b/xknxproject/models/__init__.py @@ -1,4 +1,5 @@ """Xknxproj models.""" +# flake8: noqa from .knxproject import ( Area, CommunicationObject, @@ -19,7 +20,9 @@ ComObjectRef, DeviceInstance, HardwareToPrograms, + KNXMasterData, Product, + TranslationsType, XMLArea, XMLFunction, XMLGroupAddress, @@ -29,33 +32,3 @@ XMLSpace, ) from .static import MEDIUM_TYPES, SpaceType - -__all__ = [ - "Area", - "CommunicationObject", - "Device", - "DPTType", - "Flags", - "GroupAddress", - "KNXProject", - "Line", - "ProjectInfo", - "Space", - "Function", - "GroupAddressRef", - "ComObject", - "ComObjectInstanceRef", - "ComObjectRef", - "DeviceInstance", - "HardwareToPrograms", - "Product", - "XMLArea", - "XMLGroupAddress", - "XMLLine", - "XMLSpace", - "XMLFunction", - "XMLGroupAddressRef", - "XMLProjectInformation", - "MEDIUM_TYPES", - "SpaceType", -] diff --git a/xknxproject/models/models.py b/xknxproject/models/models.py index c3db69e..9c83bfb 100644 --- a/xknxproject/models/models.py +++ b/xknxproject/models/models.py @@ -8,6 +8,8 @@ from xknxproject.models.static import SpaceType from xknxproject.zip import KNXProjContents +TranslationsType = dict[str, dict[str, str]] + class XMLGroupAddress: """Class that represents a group address.""" @@ -259,6 +261,39 @@ class ComObjectRef: datapoint_types: list[DPTType] # "DataPointType" - knx:IDREFS +@dataclass +class KNXMasterData: + """KNX Master data needed for parsing other project files.""" + + function_type_names: dict[str, str] + manufacturer_names: dict[str, str] + space_usage_mapping: dict[str, str] + translations: TranslationsType + + def _get_translation_item( + self, ref_id: str, attribute_name: str = "Text" + ) -> str | None: + """Get translation item from the translations dict.""" + if self.translations: + try: + return self.translations[ref_id][attribute_name] + except KeyError: + return None + return None + + def get_function_type_name(self, function_type_id: str) -> str: + """Get space usage name from space usage id.""" + if translated := self._get_translation_item(function_type_id): + return translated + return self.function_type_names.get(function_type_id, "") + + def get_space_usage_name(self, space_usage_id: str) -> str: + """Get space usage name from space usage id.""" + if translated := self._get_translation_item(space_usage_id): + return translated + return self.space_usage_mapping.get(space_usage_id, "") + + @dataclass class XMLSpace: """A space in the location XML.""" diff --git a/xknxproject/xml/parser.py b/xknxproject/xml/parser.py index d75498e..ce9b963 100644 --- a/xknxproject/xml/parser.py +++ b/xknxproject/xml/parser.py @@ -225,10 +225,8 @@ def recursive_convert_spaces(self, space: XMLSpace) -> Space: def load(self, language: str | None) -> None: """Load XML files.""" ( - manufacturer_names, - space_usage_names, + knx_master_data, self.language_code, - function_type_names, ) = KNXMasterLoader.load( knx_proj_contents=self.knx_proj_contents, knx_master_file=self.knx_proj_contents.root_path / "knx_master.xml", @@ -243,8 +241,7 @@ def load(self, language: str | None) -> None: self.functions, ) = ProjectLoader.load( knx_proj_contents=self.knx_proj_contents, - space_usage_names=space_usage_names, - function_type_names=function_type_names, + knx_master_data=knx_master_data, ) products_dict: dict[str, Product] = {} @@ -262,7 +259,9 @@ def load(self, language: str | None) -> None: hardware_application_map.update(_hardware_programs) for device in self.devices: - device.manufacturer_name = manufacturer_names.get(device.manufacturer, "") + device.manufacturer_name = knx_master_data.manufacturer_names.get( + device.manufacturer, "" + ) try: product = products_dict[device.product_ref]