From f69c7e6dce86c58a8df11ac0ddff0c013df18664 Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Fri, 12 Jun 2026 15:24:54 +1000 Subject: [PATCH 1/3] export and import device-tree sibling devices On standard CODESYS hardware, Ethernet, Modbus and fieldbus devices sit in the device tree as direct DEVICE children of the PLC device, next to Plc Logic, and codescribe did not track them. Add src/device_tree_import_export.py to export each such sibling as a native recursive xml file under /devices/.xml, import them back, and strip their children in Save As Template. Children that carry an Application anywhere in their subtree are excluded from the export. In compound safety projects such as the IFM CR711s the SafetyPLC and StandardPLC each carry their own Application and are already exported through their own device entrypoint, so exporting them here would duplicate them. The Communication object is also excluded since it has its own export path. Adding a _NO_EXPORT folder as a direct child of the PLC device disables the device-tree export. The existing _NO_EXPORT under Communication keeps disabling only the Communication export. Credit: reimplemented from ipfedor/codescribe commit 6e08a99e. Refs #24. --- README.md | 4 ++ src/device_tree_import_export.py | 103 +++++++++++++++++++++++++++++++ src/import_from_files.py | 3 + src/script_export_to_files.py | 3 + src/script_save_as_template.py | 6 +- tools/ci/import_smoke.py | 1 + 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/device_tree_import_export.py 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..72c9b1f --- /dev/null +++ b/src/device_tree_import_export.py @@ -0,0 +1,103 @@ +# 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): + 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 + + remove_tracked_device_tree_devices(device_obj, device_folder) + + 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 None: + raise ValueError("Cannot find device-tree device with name " + name) + read_native(os.path.join(devices_folder, child_name), device_node) + + +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 + # snapshot the children: removing while iterating the live collection skips entries + for child in list(device_node.get_children()): + child.remove() 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..0035d60 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,9 @@ def delete_old_templates(template_paths): if communication is not None: remove_tracked_communication_devices(communication) + device_folder = os.path.join(get_src_folder(template_project), 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", ] From c1b846174e670fd54a60899cbbf94ca4a0f160ea Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Fri, 12 Jun 2026 15:27:36 +1000 Subject: [PATCH 2/3] resolve tracked device list from the primary project export folder get_src_folder(template_project) points at _template_vX, which never exists on disk, so remove_tracked_device_tree_devices returned early and the template kept its tracked device-tree children. The tracked list lives in the primary project export folder. --- src/script_save_as_template.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/script_save_as_template.py b/src/script_save_as_template.py index 0035d60..fbf1565 100644 --- a/src/script_save_as_template.py +++ b/src/script_save_as_template.py @@ -54,7 +54,10 @@ def delete_old_templates(template_paths): if communication is not None: remove_tracked_communication_devices(communication) - device_folder = os.path.join(get_src_folder(template_project), device_obj.get_name()) + # 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() From 37f7baec313f735a65581148923b77a356c23304 Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Fri, 12 Jun 2026 16:48:24 +1000 Subject: [PATCH 3/3] remove and recreate tracked devices on import, skip fixed package devices Live testing on an IFM CR711s compound project showed two faults in the original import design. Children of package-fixed devices (Local_IO/Inputs, HMI/User_LEDs) cannot be removed, which aborted the whole import. And import_native pastes the archived object as a child of the receiving node rather than replacing in place, so importing a device xml onto the device itself produced a rejected HMI_1 paste dialog. Import now removes the matching device and imports the xml into the PLC device, which recreates it. Package-fixed devices that refuse removal are skipped with a message, since the project template carries their configuration, the same contract as the fixed top-level communication devices. A device missing from a fresh template-derived project imports directly instead of raising. Save As Template applies the same semantics. --- src/device_tree_import_export.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/device_tree_import_export.py b/src/device_tree_import_export.py index 72c9b1f..66a4423 100644 --- a/src/device_tree_import_export.py +++ b/src/device_tree_import_export.py @@ -62,6 +62,12 @@ def export_device_tree_siblings(device_obj, device_folder, application, communic 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 @@ -69,17 +75,21 @@ def import_device_tree_siblings(device_obj, device_folder): if no_export_device_tree(device_obj): return - remove_tracked_device_tree_devices(device_obj, device_folder) - 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 None: - raise ValueError("Cannot find device-tree device with name " + name) - read_native(os.path.join(devices_folder, child_name), device_node) + 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): @@ -98,6 +108,8 @@ def remove_tracked_device_tree_devices(device_obj, device_folder): device_node = _find_device_tree_sibling(device_obj, name) if device_node is None: continue - # snapshot the children: removing while iterating the live collection skips entries - for child in list(device_node.get_children()): - child.remove() + 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")