diff --git a/README.md b/README.md index 3ff096b..9e9a3c2 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ Exporting communication devices has been hardcoded to create folders for top-lev Devices that have no `Communication` object at all are also supported since v0.2.0. The communication step is skipped for that device and no `communication` folder is created in the export; before v0.2.0 this aborted the export, import or Save As Template for the whole device. +A second layout is also supported: on standard CODESYS hardware, Ethernet, Modbus and fieldbus devices often sit in the device tree as direct children of the PLC device, next to `Plc Logic`, rather than under a `Communication` node. These device-tree siblings are exported to `/devices/.xml` using a native recursive export, one file per top-level device. Children that contain an `Application` (for example the SafetyPLC and StandardPLC of a compound safety project) are excluded, as they are already exported through their own device entrypoint. + +**To disable the device-tree export, add a folder with the name `_NO_EXPORT` as a direct child of the PLC device.** The existing `_NO_EXPORT` folder under `Communication` continues to disable only the `Communication` export. + ## Status CODESCRIBE has been tested only on CODESYS V3.5 SP11, using the project structure supplied by the IFM CR711s packages. Version 0.2.0 was validated end-to-end on V3.5 SP11 (32-bit) with an IFM CR711S compound (Standard + SIL2) project: export/import round-trip, code generation, simulation and a hardware download. diff --git a/src/device_tree_import_export.py b/src/device_tree_import_export.py new file mode 100644 index 0000000..66a4423 --- /dev/null +++ b/src/device_tree_import_export.py @@ -0,0 +1,115 @@ +# REMEMBER: this is python 2.7 +import os + +from communication_import_export import NO_EXPORT_FOLDER_NAME +from import_export import read_native, write_native +from object_type import ObjectType, get_object_type +from util import * + +DEVICE_TREE_FOLDER_NAME = "devices" + + +def no_export_device_tree(device_obj): + # Only a direct FOLDER child of the PLC device disables this export. A _NO_EXPORT + # folder nested under Communication keeps disabling the Communication export alone. + for child in device_obj.get_children(): + if get_object_type(child) == ObjectType.FOLDER and child.get_name() == NO_EXPORT_FOLDER_NAME: + return True + return False + + +def _find_device_tree_sibling(device_obj, name): + # Match the export scope: only direct DEVICE children of the PLC device. + for child in device_obj.get_children(): + if get_object_type(child) == ObjectType.DEVICE and child.get_name() == name: + return child + return None + + +def export_device_tree_siblings(device_obj, device_folder, application, communication): + """ + Export device-tree devices that sit next to Plc Logic as direct children of the PLC device + (Ethernet, Modbus, fieldbus). Each one is written as a single native recursive xml file under + /devices. Returns True when at least one device was exported. + """ + if no_export_device_tree(device_obj): + return False + + siblings = [] + for child in device_obj.get_children(): + if get_object_type(child) != ObjectType.DEVICE: + continue + if communication is not None and child == communication: + continue + # Skip children that carry an Application anywhere in their subtree, not just the + # Application passed in. In compound safety projects (e.g. the IFM CR711s) the + # SafetyPLC and StandardPLC are DEVICE children that each carry their own + # Application and are already exported through their own device entrypoint. + if first_or_none(child.find("Application", recursive=True)) is not None: + continue + siblings.append(child) + + if len(siblings) < 1: + return False + + devices_folder = os.path.join(device_folder, DEVICE_TREE_FOLDER_NAME) + os.mkdir(devices_folder) + + for child in siblings: + write_native(child, os.path.join(devices_folder, child.get_name() + ".xml"), recursive=True) + + return True + + +def import_device_tree_siblings(device_obj, device_folder): + # import_native always pastes the archived object as a child of the receiving node, + # it never replaces in place. So the only way to update a tracked device is to + # remove it and import the xml into the PLC device. Devices fixed by the device + # package (e.g. Local_IO and HMI on IFM hardware) cannot be removed; their + # configuration is carried by the project template, like the fixed top-level + # communication devices, so they are skipped with a message. + devices_folder = os.path.join(device_folder, DEVICE_TREE_FOLDER_NAME) + if not os.path.exists(devices_folder): + return + + if no_export_device_tree(device_obj): + return + + for child_name in sorted(os.listdir(devices_folder)): + name, ext = os.path.splitext(child_name) + if ext != ".xml": + continue + + device_node = _find_device_tree_sibling(device_obj, name) + if device_node is not None: + try: + device_node.remove() + except Exception: + print("Cannot remove fixed device " + name + ", skipping its import (the template carries its configuration)") + continue + # Missing device (fresh project from a template) and removed device both + # import the same way: the xml recreates the device under the PLC device. + read_native(os.path.join(devices_folder, child_name), device_obj) + + +def remove_tracked_device_tree_devices(device_obj, device_folder): + devices_folder = os.path.join(device_folder, DEVICE_TREE_FOLDER_NAME) + if not os.path.exists(devices_folder): + return + + if no_export_device_tree(device_obj): + return + + for child_name in os.listdir(devices_folder): + name, ext = os.path.splitext(child_name) + if ext != ".xml": + continue + + device_node = _find_device_tree_sibling(device_obj, name) + if device_node is None: + continue + try: + device_node.remove() + except Exception: + # Fixed package devices stay in the template; import skips them too. + print("Cannot remove fixed device " + name + ", leaving it in the template") diff --git a/src/import_from_files.py b/src/import_from_files.py index f3b3956..593b352 100644 --- a/src/import_from_files.py +++ b/src/import_from_files.py @@ -1,6 +1,7 @@ import os from communication_import_export import import_communication +from device_tree_import_export import import_device_tree_siblings from entrypoint import find_application, find_communication, get_device_entrypoints, get_src_folder from import_export import * from util import * @@ -94,3 +95,5 @@ def import_from_files(project): communication = find_communication(device_obj) if communication is not None: import_communication(communication, device_folder) + + import_device_tree_siblings(device_obj, device_folder) diff --git a/src/script_export_to_files.py b/src/script_export_to_files.py index 19d2077..fae8223 100644 --- a/src/script_export_to_files.py +++ b/src/script_export_to_files.py @@ -6,6 +6,7 @@ import scriptengine # type: ignore from communication_import_export import export_communication +from device_tree_import_export import export_device_tree_siblings from entrypoint import find_application, find_communication, get_device_entrypoints, get_src_folder from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, write_native from object_type import ObjectType, get_object_type @@ -62,6 +63,8 @@ def export_child(child_obj, parent_obj, parent_folder_path): if communication is not None: export_communication(communication, device_folder) + export_device_tree_siblings(device_obj, device_folder, application, communication) + finalize_export_folder(src_folder, staging_folder) except Exception as e: print(e) diff --git a/src/script_save_as_template.py b/src/script_save_as_template.py index b2a8dfe..fbf1565 100644 --- a/src/script_save_as_template.py +++ b/src/script_save_as_template.py @@ -7,7 +7,8 @@ import scriptengine # type: ignore from communication_import_export import remove_tracked_communication_devices -from entrypoint import find_application, find_communication, get_device_entrypoints +from device_tree_import_export import remove_tracked_device_tree_devices +from entrypoint import find_application, find_communication, get_device_entrypoints, get_src_folder from import_export import * from project_template import find_template_paths_and_versions, generate_template_path from util import * @@ -53,6 +54,12 @@ def delete_old_templates(template_paths): if communication is not None: remove_tracked_communication_devices(communication) + # The list of tracked devices lives in the primary project's export folder. + # The template copy has its own file name, so its src folder never exists + # on disk and would make this a no-op. + device_folder = os.path.join(get_src_folder(scriptengine.projects.primary), device_obj.get_name()) + remove_tracked_device_tree_devices(device_obj, device_folder) + template_project.save() if len(template_paths) > 0: diff --git a/tools/ci/import_smoke.py b/tools/ci/import_smoke.py index d7eb9c1..861c50a 100644 --- a/tools/ci/import_smoke.py +++ b/tools/ci/import_smoke.py @@ -23,6 +23,7 @@ "entrypoint", "import_export", "communication_import_export", + "device_tree_import_export", "import_from_files", "project_template", ]