""" export_io_from_CAx : Script que sirve para exraer los IOs de un proyecto de TIA Portal y generar un archivo Markdown con la información. """ import os import sys import tkinter as tk from tkinter import filedialog import traceback from lxml import etree as ET import json from pathlib import Path import re import math # Needed for ceil import pandas as pd # For Excel generation script_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ) sys.path.append(script_root) from backend.script_utils import load_configuration # --- extract_aml_data function (Unchanged from v15) --- def extract_aml_data(root): """(v15 logic - Unchanged) Extracts data, correcting PLC network association lookup.""" data = { "plcs": {}, "networks": {}, "devices": {}, "links_by_source": {}, "links_by_target": {}, "connections": [], } device_id_to_parent_device = {} all_elements = root.xpath(".//*[local-name()='InternalElement']") print( f"Pass 1: Found {len(all_elements)} InternalElement(s). Populating device dictionary..." ) # (Pass 1 logic remains unchanged) for elem in all_elements: elem_id = elem.get("ID", None) if not elem_id: continue device_info = { "name": elem.get("Name", "N/A"), "id": elem_id, "class": "N/A", "type_identifier": "N/A", "order_number": "N/A", "type_name": "N/A", "firmware_version": "N/A", "position": elem.get("PositionNumber", "N/A"), "attributes": {}, "interfaces": [], "network_nodes": [], "io_addresses": [], "children_ids": [ c.get("ID") for c in elem.xpath("./*[local-name()='InternalElement']") if c.get("ID") ], "parent_id": ( elem.xpath("parent::*[local-name()='InternalElement']/@ID")[0] if elem.xpath("parent::*[local-name()='InternalElement']") else None ), } class_tag = elem.xpath("./*[local-name()='SystemUnitClass']") device_info["class"] = ( class_tag[0].get("Path", elem.get("RefBaseSystemUnitPath", "N/A")) if class_tag else elem.get("RefBaseSystemUnitPath", "N/A") ) attributes = elem.xpath("./*[local-name()='Attribute']") for attr in attributes: attr_name = attr.get("Name", "") value_elem = attr.xpath("./*[local-name()='Value']/text()") attr_value = value_elem[0] if value_elem else "" device_info["attributes"][attr_name] = attr_value if attr_name == "TypeIdentifier": device_info["type_identifier"] = attr_value if "OrderNumber:" in attr_value: device_info["order_number"] = attr_value.split("OrderNumber:")[ -1 ].strip() elif attr_name == "TypeName": device_info["type_name"] = attr_value elif attr_name == "FirmwareVersion": device_info["firmware_version"] = attr_value elif attr_name == "Address": address_parts = attr.xpath("./*[local-name()='Attribute']") for part in address_parts: addr_details = { "area": part.get("Name", "?"), "start": "N/A", "length": "N/A", "type": "N/A", } start_val = part.xpath( "./*[local-name()='Attribute'][@Name='StartAddress']/*[local-name()='Value']/text()" ) len_val = part.xpath( "./*[local-name()='Attribute'][@Name='Length']/*[local-name()='Value']/text()" ) type_val = part.xpath( "./*[local-name()='Attribute'][@Name='IoType']/*[local-name()='Value']/text()" ) if start_val: addr_details["start"] = start_val[0] if len_val: addr_details["length"] = len_val[0] if type_val: addr_details["type"] = type_val[0] if addr_details["start"] != "N/A": device_info["io_addresses"].append(addr_details) interfaces = elem.xpath("./*[local-name()='ExternalInterface']") for interface in interfaces: device_info["interfaces"].append( { "name": interface.get("Name", "N/A"), "id": interface.get("ID", "N/A"), "ref_base_class": interface.get("RefBaseClassPath", "N/A"), } ) network_node_elements = elem.xpath( "./*[local-name()='InternalElement'][*[local-name()='SupportedRoleClass'][contains(@RefRoleClassPath, 'Node')]]" ) if not network_node_elements and elem.xpath( "./*[local-name()='SupportedRoleClass'][contains(@RefRoleClassPath, 'Node')]" ): network_node_elements = [elem] for node_elem in network_node_elements: node_id = node_elem.get("ID") if not node_id: continue node_info = { "id": node_id, "name": node_elem.get("Name", device_info["name"]), "type": "N/A", "address": "N/A", } type_attr = node_elem.xpath( "./*[local-name()='Attribute'][@Name='Type']/*[local-name()='Value']/text()" ) addr_attr = node_elem.xpath( "./*[local-name()='Attribute'][@Name='NetworkAddress']/*[local-name()='Value']/text()" ) if type_attr: node_info["type"] = type_attr[0] if addr_attr: node_info["address"] = addr_attr[0] if node_info["address"] == "N/A": parent_addr_attr = elem.xpath( "./*[local-name()='Attribute'][@Name='NetworkAddress']/*[local-name()='Value']/text()" ) if parent_addr_attr: node_info["address"] = parent_addr_attr[0] if node_info["type"] == "N/A": parent_type_attr = elem.xpath( "./*[local-name()='Attribute'][@Name='Type']/*[local-name()='Value']/text()" ) if parent_type_attr: node_info["type"] = parent_type_attr[0] if node_info["address"] != "N/A": len_attr = node_elem.xpath( "./*[local-name()='Attribute'][@Name='Length']/*[local-name()='Value']/text()" ) node_info["length"] = len_attr[0] if len_attr else "N/A" device_info["network_nodes"].append(node_info) device_id_to_parent_device[node_id] = elem_id data["devices"][elem_id] = device_info print("Pass 2: Identifying PLCs and Networks (Refined v2)...") plc_ids_found = set() elem_map = {elem.get("ID"): elem for elem in all_elements if elem.get("ID")} for dev_id, device in data["devices"].items(): is_plc = False plc_order_prefixes = [ "6ES7 516-3FP03", "6ES7 151", "6ES7 31", "6ES7 41", "6ES7 51", ] if any( device.get("order_number", "N/A").startswith(prefix) for prefix in plc_order_prefixes ): is_plc = True elif ( "CPU" in device.get("type_name", "").upper() or "PLC" in device.get("type_name", "").upper() ): is_plc = True if is_plc: parent_id = device.get("parent_id") is_child_of_plc = False current_parent = parent_id while current_parent: if current_parent in plc_ids_found: is_child_of_plc = True break current_parent = ( data["devices"].get(current_parent, {}).get("parent_id") ) if not is_child_of_plc: if dev_id not in plc_ids_found: print( f" Identified PLC: {device['name']} ({dev_id}) - Type: {device.get('type_name', 'N/A')} OrderNo: {device.get('order_number', 'N/A')}" ) device["connected_networks"] = {} data["plcs"][dev_id] = device plc_ids_found.add(dev_id) is_network = False net_type = "Unknown" elem = elem_map.get(dev_id) if elem is not None: role_classes = elem.xpath( "./*[local-name()='SupportedRoleClass']/@RefRoleClassPath" ) is_subnet_by_role = any("SUBNET" in rc.upper() for rc in role_classes) if is_subnet_by_role: is_network = True # Check role classes first for rc in role_classes: rc_upper = rc.upper() if "PROFINET" in rc_upper or "ETHERNET" in rc_upper: net_type = "Ethernet/Profinet" break elif "PROFIBUS" in rc_upper: net_type = "Profibus" break # If still unknown, check the Type attribute (crucial for PROFINET) if net_type == "Unknown": type_attr = device.get("attributes", {}).get("Type", "") if type_attr.upper() == "ETHERNET": net_type = "Ethernet/Profinet" elif type_attr.upper() == "PROFIBUS": net_type = "Profibus" # Finally, check device name as fallback if net_type == "Unknown": if "PROFIBUS" in device["name"].upper(): net_type = "Profibus" elif ( "ETHERNET" in device["name"].upper() or "PROFINET" in device["name"].upper() or "PN/IE" in device["name"].upper() # Add common PROFINET naming pattern ): net_type = "Ethernet/Profinet" if is_network: if dev_id not in data["networks"]: print( f" Identified Network: {device['name']} ({dev_id}) Type: {net_type}" ) data["networks"][dev_id] = { "name": device["name"], "type": net_type, "devices_on_net": {}, } print("Pass 3: Processing InternalLinks (Robust Network Mapping & IO)...") internal_links = root.xpath(".//*[local-name()='InternalLink']") print(f"Found {len(internal_links)} InternalLink(s).") conn_id_counter = 0 for link in internal_links: conn_id_counter += 1 link_name = link.get("Name", f"Link_{conn_id_counter}") side_a_ref = link.get("RefPartnerSideA", "") side_b_ref = link.get("RefPartnerSideB", "") side_a_match = re.match(r"([^:]+):?(.*)", side_a_ref) side_b_match = re.match(r"([^:]+):?(.*)", side_b_ref) side_a_id = side_a_match.group(1) if side_a_match else "N/A" side_a_suffix = ( side_a_match.group(2) if side_a_match and side_a_match.group(2) else side_a_id ) side_b_id = side_b_match.group(1) if side_b_match else "N/A" side_b_suffix = ( side_b_match.group(2) if side_b_match and side_b_match.group(2) else side_b_id ) network_id, device_node_id = None, None side_a_is_network = side_a_id in data["networks"] side_b_is_network = side_b_id in data["networks"] if side_a_is_network and not side_b_is_network: network_id, device_node_id = side_a_id, side_b_id elif side_b_is_network and not side_a_is_network: network_id, device_node_id = side_b_id, side_a_id elif side_a_is_network and side_b_is_network: continue elif not side_a_is_network and not side_b_is_network: pass if network_id and device_node_id: linked_device_id = device_id_to_parent_device.get(device_node_id) if not linked_device_id and device_node_id in data["devices"]: linked_device_id = device_node_id if linked_device_id and linked_device_id in data["devices"]: device_info = data["devices"].get(linked_device_id) if not device_info: continue address = "N/A" node_info_for_addr = data["devices"].get(device_node_id, {}) for node in node_info_for_addr.get("network_nodes", []): if node.get("id") == device_node_id: address = node.get("address", "N/A") break if address == "N/A": address = node_info_for_addr.get("attributes", {}).get( "NetworkAddress", "N/A" ) if address == "N/A": address = device_info.get("attributes", {}).get( "NetworkAddress", "N/A" ) node_name_for_log = node_info_for_addr.get("name", device_node_id) print( f" Mapping Device/Node '{node_name_for_log}' (NodeID:{device_node_id}, Addr:{address}) to Network '{data['networks'][network_id]['name']}'" ) data["networks"][network_id]["devices_on_net"][device_node_id] = address potential_plc_id = None interface_id = None interface_info = None node_check_info = data["devices"].get(device_node_id) if node_check_info: if device_node_id in data["plcs"]: potential_plc_id = device_node_id else: interface_id = node_check_info.get("parent_id") if interface_id and interface_id in data["devices"]: interface_info = data["devices"].get(interface_id) if interface_info: if interface_id in data["plcs"]: potential_plc_id = interface_id elif ( interface_info.get("parent_id") and interface_info["parent_id"] in data["plcs"] ): potential_plc_id = interface_info["parent_id"] # Enhanced PLC search: look for PLCs in the entire hierarchy if not potential_plc_id: # Search for PLCs in the hierarchy starting from linked_device_id current_search_id = linked_device_id search_depth = 0 max_search_depth = 10 while current_search_id and search_depth < max_search_depth: # Check current device and all its children for PLCs device_to_check = data["devices"].get(current_search_id) if device_to_check: # Check if current device has PLCs as children for child_id in device_to_check.get("children_ids", []): if child_id in data["plcs"]: potential_plc_id = child_id print(f" --> Found PLC in children: {data['plcs'][child_id].get('name', 'Unknown PLC')} (ID: {child_id})") break if potential_plc_id: break # Move up to parent current_search_id = device_to_check.get("parent_id") search_depth += 1 else: break if potential_plc_id: plc_object = data["plcs"][potential_plc_id] if "connected_networks" not in plc_object: plc_object["connected_networks"] = {} if network_id not in plc_object.get("connected_networks", {}): print( f" --> Associating Network '{data['networks'][network_id]['name']}' with PLC '{plc_object.get('name', 'Unknown PLC')}' (via Node '{node_name_for_log}' Addr: {address})" ) data["plcs"][potential_plc_id]["connected_networks"][ network_id ] = address elif ( plc_object["connected_networks"][network_id] == "N/A" and address != "N/A" ): print( f" --> Updating address for Network '{data['networks'][network_id]['name']}' on PLC '{plc_object.get('name', 'Unknown PLC')}' to: {address}" ) data["plcs"][potential_plc_id]["connected_networks"][ network_id ] = address else: print( f" Warning: Could not map linked device ID {linked_device_id} (from NodeID {device_node_id}) to any known device." ) continue if not network_id: # Generic links source_id, source_suffix, target_id, target_suffix = ( side_a_id, side_a_suffix, side_b_id, side_b_suffix, ) if ("Channel" in side_b_suffix or "Parameter" in side_b_suffix) and ( "Channel" not in side_a_suffix and "Parameter" not in side_a_suffix ): source_id, source_suffix, target_id, target_suffix = ( side_b_id, side_b_suffix, side_a_id, side_a_suffix, ) if source_id != "N/A" and target_id != "N/A": if source_id not in data["links_by_source"]: data["links_by_source"][source_id] = [] if target_id not in data["links_by_target"]: data["links_by_target"][target_id] = [] link_detail = { "name": link_name, "source_id": source_id, "source_suffix": source_suffix, "target_id": target_id, "target_suffix": target_suffix, "source_device_name": data["devices"] .get(source_id, {}) .get("name", source_id), "target_device_name": data["devices"] .get(target_id, {}) .get("name", target_id), } data["links_by_source"][source_id].append(link_detail) data["links_by_target"][target_id].append(link_detail) data["connections"].append(link_detail) print("Data extraction and structuring complete.") return data # --- Helper Function for Recursive IO Search (Unchanged from v20) --- def find_io_recursively(device_id, project_data, module_context): """ Recursively finds all IO addresses under a given device ID, using module_context for details of the main hardware module. module_context = {"id": ..., "name": ..., "order_number": ..., "type_name": ...} """ io_list = [] device_info = project_data.get("devices", {}).get(device_id) if not device_info: return io_list if device_info.get("io_addresses"): # Slot position is from the current device_info (which holds the IO) # It's often found in attributes.PositionNumber for sub-elements. slot_pos = device_info.get("attributes", {}).get("PositionNumber", device_info.get("position", "N/A")) for addr in device_info["io_addresses"]: io_list.append( { "module_id": module_context["id"], "module_name": module_context["name"], "module_pos": slot_pos, # Slot of the IO sub-element "module_order_number": module_context["order_number"], "module_type_name": module_context["type_name"], **addr, } ) children_ids = device_info.get("children_ids", []) for child_id in children_ids: if child_id != device_id: # Basic loop prevention # The module_context remains the same as we recurse *within* a main module's structure io_list.extend(find_io_recursively(child_id, project_data, module_context)) return io_list # --- generate_io_summary_file function (Updated) --- def generate_io_summary_file(all_plc_io_for_table, md_file_path, plc_name, project_data, output_root_path): """ Generates a Hardware.md file with the IO summary table. If there's only one PLC, creates it in the root directory, otherwise creates PLC-specific named files. """ # Determine if this is the only PLC in the project plcs_count = len(project_data.get("plcs", {})) is_single_plc = plcs_count == 1 if is_single_plc: # For single PLC: create Hardware.md in the root directory hardware_file_path = os.path.join(output_root_path, "Hardware.md") file_title = f"# IO Summary Table for PLC: {plc_name}" else: # For multiple PLCs: create [PLC_Name]_Hardware.md in PLC's directory hardware_file_path = os.path.join(os.path.dirname(md_file_path), f"{sanitize_filename(plc_name)}_Hardware.md") file_title = f"# IO Summary Table for PLC: {plc_name}" markdown_lines = [file_title, ""] if all_plc_io_for_table: # Define table headers headers = [ "Network", "Type", "Address", "Device Name", "Sub-Device", "OrderNo", "Type", "IO Type", "IO Address", "Number of Bits" ] markdown_lines.append("| " + " | ".join(headers) + " |") markdown_lines.append("|-" + "-|-".join(["---"] * len(headers)) + "-|") # Sort the collected data sorted_table_data = sorted(all_plc_io_for_table, key=lambda x: x["SortKey"]) # Add rows to the table for row_data in sorted_table_data: row = [ row_data.get("Network", "N/A"), row_data.get("Network Type", "N/A"), row_data.get("Device Address", "N/A"), row_data.get("Device Name", "N/A"), row_data.get("Sub-Device", "N/A"), row_data.get("Sub-Device OrderNo", "N/A"), row_data.get("Sub-Device Type", "N/A"), row_data.get("IO Type", "N/A"), f"`{row_data.get('IO Address', 'N/A')}`", # Format IO Address as code row_data.get("Number of Bits", "N/A"), ] # Escape pipe characters within cell content if necessary row = [str(cell).replace('|', '\\|') for cell in row] markdown_lines.append("| " + " | ".join(row) + " |") else: markdown_lines.append("*No IO data found for this PLC.*") try: with open(hardware_file_path, "w", encoding="utf-8") as f: f.write("\n".join(markdown_lines)) print(f"IO summary table written to: {hardware_file_path}") except Exception as e: print(f"ERROR writing Hardware.md file {hardware_file_path}: {e}") traceback.print_exc() return hardware_file_path # --- generate_io_excel_report function --- def generate_io_excel_report(project_data, excel_file_path, target_plc_id, output_root_path): """ Genera un archivo Excel con información detallada de IOs por nodos del PLC. """ plc_info = project_data.get("plcs", {}).get(target_plc_id) if not plc_info: print(f"PLC ID '{target_plc_id}' not found in project data.") return plc_name = plc_info.get('name', target_plc_id) print(f"Generating Excel IO report for PLC: {plc_name}") # Lista para almacenar todas las filas del Excel excel_rows = [] # Procesar las redes conectadas al PLC plc_networks = plc_info.get("connected_networks", {}) if not plc_networks: # Si no hay redes, crear una fila básica del PLC excel_rows.append({ 'PLC Name': plc_name, 'Network Path': 'No networks connected', 'Network Type': 'N/A', 'Device Address': 'N/A', 'Device Name': plc_name, 'Device Type': plc_info.get("type_name", "N/A"), 'Order Number': plc_info.get("order_number", "N/A"), 'Firmware Version': plc_info.get("firmware_version", "N/A"), 'Position': plc_info.get("position", "N/A"), 'IO Input Start Address': 'N/A', 'IO Input End Address': 'N/A', 'IO Output Start Address': 'N/A', 'IO Output End Address': 'N/A', 'Total Input Bits': 0, 'Total Output Bits': 0, 'Module Name': 'N/A', 'Module Type': 'N/A', 'Module Order Number': 'N/A' }) else: # Procesar cada red conectada for net_id, plc_addr_on_net in plc_networks.items(): net_info = project_data.get("networks", {}).get(net_id) if not net_info: continue network_name = net_info.get('name', net_id) network_type = net_info.get('type', 'Unknown') devices_on_net = net_info.get("devices_on_net", {}) # Identificar nodos que pertenecen al PLC para excluirlos de la lista de dispositivos plc_interface_and_node_ids = set() for node in plc_info.get("network_nodes", []): plc_interface_and_node_ids.add(node["id"]) interface_id_lookup = project_data["devices"].get(node["id"], {}).get("parent_id") if interface_id_lookup: plc_interface_and_node_ids.add(interface_id_lookup) plc_interface_and_node_ids.add(target_plc_id) # Filtrar dispositivos que no son interfaces del PLC other_devices = [ (node_id, node_addr) for node_id, node_addr in devices_on_net.items() if node_id not in plc_interface_and_node_ids ] if not other_devices: # Si no hay otros dispositivos, crear fila solo para el PLC en esta red excel_rows.append({ 'PLC Name': plc_name, 'Network Path': f"{network_name} -> {plc_name}", 'Network Type': network_type, 'Device Address': plc_addr_on_net, 'Device Name': plc_name, 'Device Type': plc_info.get("type_name", "N/A"), 'Order Number': plc_info.get("order_number", "N/A"), 'Firmware Version': plc_info.get("firmware_version", "N/A"), 'Position': plc_info.get("position", "N/A"), 'IO Input Start Address': 'N/A', 'IO Input End Address': 'N/A', 'IO Output Start Address': 'N/A', 'IO Output End Address': 'N/A', 'Total Input Bits': 0, 'Total Output Bits': 0, 'Module Name': 'PLC Main Unit', 'Module Type': plc_info.get("type_name", "N/A"), 'Module Order Number': plc_info.get("order_number", "N/A") }) else: # Procesar cada dispositivo en la red for node_id, node_addr in other_devices: node_info = project_data.get("devices", {}).get(node_id) if not node_info: continue # Determinar la estructura jerárquica del dispositivo interface_id = node_info.get("parent_id") interface_info = None actual_device_id = None actual_device_info = None if interface_id: interface_info = project_data.get("devices", {}).get(interface_id) if interface_info: actual_device_id = interface_info.get("parent_id") if actual_device_id: actual_device_info = project_data.get("devices", {}).get(actual_device_id) # Determinar qué información mostrar display_info = actual_device_info if actual_device_info else (interface_info if interface_info else node_info) display_id = actual_device_id if actual_device_info else (interface_id if interface_info else node_id) device_name = display_info.get("name", display_id) device_type = display_info.get("type_name", "N/A") device_order = display_info.get("order_number", "N/A") device_position = display_info.get("position", "N/A") firmware_version = display_info.get("firmware_version", "N/A") # Construir el path de red network_path = f"{network_name} ({network_type}) -> {device_name} @ {node_addr}" # Buscar IOs recursivamente io_search_root_id = display_id io_search_root_info = project_data.get("devices", {}).get(io_search_root_id) aggregated_io_addresses = [] # Buscar IOs en la estructura padre si existe parent_structure_id = io_search_root_info.get("parent_id") if io_search_root_info else None if parent_structure_id: # Buscar IOs en dispositivos hermanos bajo la misma estructura padre for dev_scan_id, dev_scan_info in project_data.get("devices", {}).items(): if dev_scan_info.get("parent_id") == parent_structure_id: module_context = { "id": dev_scan_id, "name": dev_scan_info.get("name", dev_scan_id), "order_number": dev_scan_info.get("order_number", "N/A"), "type_name": dev_scan_info.get("type_name", "N/A") } io_from_sibling = find_io_recursively(dev_scan_id, project_data, module_context) aggregated_io_addresses.extend(io_from_sibling) elif io_search_root_id: # Buscar IOs directamente en el dispositivo module_context = { "id": io_search_root_id, "name": io_search_root_info.get("name", io_search_root_id), "order_number": io_search_root_info.get("order_number", "N/A"), "type_name": io_search_root_info.get("type_name", "N/A") } aggregated_io_addresses = find_io_recursively(io_search_root_id, project_data, module_context) # Procesar IOs por módulo if aggregated_io_addresses: # Agrupar IOs por módulo ios_by_module = {} for addr_info in aggregated_io_addresses: module_id = addr_info.get("module_id") if module_id not in ios_by_module: ios_by_module[module_id] = { 'module_info': { 'name': addr_info.get('module_name', '?'), 'type': addr_info.get('module_type_name', 'N/A'), 'order': addr_info.get('module_order_number', 'N/A'), 'position': addr_info.get('module_pos', 'N/A') }, 'inputs': [], 'outputs': [] } # Clasificar IO como input u output io_type = addr_info.get("type", "").lower() if io_type == "input": ios_by_module[module_id]['inputs'].append(addr_info) elif io_type == "output": ios_by_module[module_id]['outputs'].append(addr_info) # Crear una fila por cada módulo con IOs for module_id, module_data in ios_by_module.items(): module_info = module_data['module_info'] # Calcular direcciones de entrada - formato simplificado input_start_addr = 'N/A' input_end_addr = 'N/A' total_input_bits = 0 for addr_info in module_data['inputs']: start_str = addr_info.get("start", "?") length_str = addr_info.get("length", "?") try: start_byte = int(start_str) length_bits = int(length_str) length_bytes = math.ceil(length_bits / 8.0) if length_bits > 0 and length_bytes == 0: length_bytes = 1 end_byte = start_byte + length_bytes - 1 # Para múltiples rangos, tomar el primer inicio y último fin if input_start_addr == 'N/A': input_start_addr = start_byte input_end_addr = end_byte else: input_start_addr = min(input_start_addr, start_byte) input_end_addr = max(input_end_addr, end_byte) total_input_bits += length_bits except: # En caso de error, mantener N/A pass # Calcular direcciones de salida - formato simplificado output_start_addr = 'N/A' output_end_addr = 'N/A' total_output_bits = 0 for addr_info in module_data['outputs']: start_str = addr_info.get("start", "?") length_str = addr_info.get("length", "?") try: start_byte = int(start_str) length_bits = int(length_str) length_bytes = math.ceil(length_bits / 8.0) if length_bits > 0 and length_bytes == 0: length_bytes = 1 end_byte = start_byte + length_bytes - 1 # Para múltiples rangos, tomar el primer inicio y último fin if output_start_addr == 'N/A': output_start_addr = start_byte output_end_addr = end_byte else: output_start_addr = min(output_start_addr, start_byte) output_end_addr = max(output_end_addr, end_byte) total_output_bits += length_bits except: # En caso de error, mantener N/A pass excel_rows.append({ 'PLC Name': plc_name, 'Network Path': network_path, 'Network Type': network_type, 'Device Address': node_addr, 'Device Name': device_name, 'Device Type': device_type, 'Order Number': device_order, 'Firmware Version': firmware_version, 'Position': device_position, 'IO Input Start Address': input_start_addr, 'IO Input End Address': input_end_addr, 'IO Output Start Address': output_start_addr, 'IO Output End Address': output_end_addr, 'Total Input Bits': total_input_bits, 'Total Output Bits': total_output_bits, 'Module Name': module_info['name'], 'Module Type': module_info['type'], 'Module Order Number': module_info['order'] }) else: # Dispositivo sin IOs excel_rows.append({ 'PLC Name': plc_name, 'Network Path': network_path, 'Network Type': network_type, 'Device Address': node_addr, 'Device Name': device_name, 'Device Type': device_type, 'Order Number': device_order, 'Firmware Version': firmware_version, 'Position': device_position, 'IO Input Start Address': 'N/A', 'IO Input End Address': 'N/A', 'IO Output Start Address': 'N/A', 'IO Output End Address': 'N/A', 'Total Input Bits': 0, 'Total Output Bits': 0, 'Module Name': 'N/A', 'Module Type': 'N/A', 'Module Order Number': 'N/A' }) # Crear DataFrame y guardar Excel if excel_rows: df = pd.DataFrame(excel_rows) # Agregar columna de ID único para compatibilidad con x7_update_CAx df['Unique_ID'] = df['PLC Name'] + "+" + df['Device Name'] # Reordenar columnas para mejor legibilidad column_order = [ 'PLC Name', 'Network Path', 'Network Type', 'Device Address', 'Device Name', 'Device Type', 'Order Number', 'Firmware Version', 'Position', 'Module Name', 'Module Type', 'Module Order Number', 'IO Input Start Address', 'IO Input End Address', 'IO Output Start Address', 'IO Output End Address', 'Total Input Bits', 'Total Output Bits', 'Unique_ID' # Agregar al final para compatibilidad ] df = df.reindex(columns=column_order) try: # Guardar como Excel con formato with pd.ExcelWriter(excel_file_path, engine='openpyxl') as writer: df.to_excel(writer, sheet_name='IO Report', index=False) # Ajustar ancho de columnas worksheet = writer.sheets['IO Report'] for column in worksheet.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass adjusted_width = min(max_length + 2, 50) # Máximo 50 caracteres worksheet.column_dimensions[column_letter].width = adjusted_width print(f"Excel IO report saved to: {excel_file_path}") print(f"Total rows in report: {len(excel_rows)}") except Exception as e: print(f"ERROR saving Excel file {excel_file_path}: {e}") traceback.print_exc() else: print("No data to write to Excel file.") # --- generate_markdown_tree function --- def generate_markdown_tree(project_data, md_file_path, target_plc_id, output_root_path): """(Modified) Generates hierarchical Markdown for a specific PLC.""" plc_info = project_data.get("plcs", {}).get(target_plc_id) plc_name_for_title = "Unknown PLC" if plc_info: plc_name_for_title = plc_info.get('name', target_plc_id) # v31: Initialize list to store all IO data for the summary table for this PLC all_plc_io_for_table = [] markdown_lines = [f"# Hardware & IO Summary for PLC: {plc_name_for_title}", ""] if not plc_info: markdown_lines.append(f"*Details for PLC ID '{target_plc_id}' not found in the project data.*") try: with open(md_file_path, "w", encoding="utf-8") as f: f.write("\n".join(markdown_lines)) print(f"Markdown summary (PLC not found) written to: {md_file_path}") except Exception as e: print(f"ERROR writing Markdown file {md_file_path}: {e}") return # Content previously in the loop now directly uses plc_info and target_plc_id markdown_lines.append(f"\n## PLC: {plc_info.get('name', target_plc_id)}") type_name = plc_info.get("type_name", "N/A") order_num = plc_info.get("order_number", "N/A") firmware = plc_info.get("firmware_version", "N/A") if type_name and type_name != "N/A": markdown_lines.append(f"- **Type Name:** `{type_name}`") if order_num and order_num != "N/A": markdown_lines.append(f"- **Order Number:** `{order_num}`") if firmware and firmware != "N/A": markdown_lines.append(f"- **Firmware:** `{firmware}`") plc_networks = plc_info.get("connected_networks", {}) markdown_lines.append("\n- **Networks:**") if not plc_networks: markdown_lines.append( " - *No network connections found associated with this PLC object.*" ) else: sorted_network_items = sorted( plc_networks.items(), key=lambda item: project_data.get("networks", {}) .get(item[0], {}) .get("name", item[0]), ) for net_id, plc_addr_on_net in sorted_network_items: net_info = project_data.get("networks", {}).get(net_id) if not net_info: markdown_lines.append( f" - !!! Error: Network info missing for ID {net_id} !!!" ) continue markdown_lines.append( f" - ### {net_info.get('name', net_id)} ({net_info.get('type', 'Unknown')})" ) markdown_lines.append( f" - PLC Address on this Net: `{plc_addr_on_net}`" ) markdown_lines.append(f" - **Devices on Network:**") devices_on_this_net = net_info.get("devices_on_net", {}) def sort_key(item): node_id, node_addr = item try: parts = [int(p) for p in re.findall(r"\d+", node_addr)] return parts except: return [float("inf")] plc_interface_and_node_ids = set() for node in plc_info.get("network_nodes", []): plc_interface_and_node_ids.add(node["id"]) interface_id_lookup = ( project_data["devices"].get(node["id"], {}).get("parent_id") ) if interface_id_lookup: plc_interface_and_node_ids.add(interface_id_lookup) plc_interface_and_node_ids.add(target_plc_id) # Use target_plc_id here other_device_items = sorted( [ (node_id, node_addr) for node_id, node_addr in devices_on_this_net.items() if node_id not in plc_interface_and_node_ids ], key=sort_key, ) if not other_device_items: markdown_lines.append(" - *None (besides PLC interfaces)*") else: # --- Display Logic with Sibling IO Aggregation & Aesthetics --- for node_id, node_addr in other_device_items: # v31: Initialize list for table data for the current device being processed current_device_io_for_table = [] node_info = project_data.get("devices", {}).get(node_id) if not node_info: markdown_lines.append( f" - !!! Error: Node info missing for ID {node_id} Addr: {node_addr} !!!" ) continue interface_id = node_info.get("parent_id") interface_info_dev = None # Renamed to avoid conflict actual_device_id = None actual_device_info = None rack_id = None # rack_info = None # rack_info was not used if interface_id: interface_info_dev = project_data.get("devices", {}).get( interface_id ) if interface_info_dev: actual_device_id = interface_info_dev.get("parent_id") if actual_device_id: actual_device_info = project_data.get( "devices", {} ).get(actual_device_id) if actual_device_info: potential_rack_id = actual_device_info.get( "parent_id" ) if potential_rack_id: potential_rack_info = project_data.get( "devices", {} ).get(potential_rack_id) if potential_rack_info and ( "Rack" in potential_rack_info.get("name", "") or potential_rack_info.get("position") is None ): rack_id = potential_rack_id # rack_info = potential_rack_info # Not used display_info_title = ( actual_device_info if actual_device_info else (interface_info_dev if interface_info_dev else node_info) ) display_id_title = ( actual_device_id if actual_device_info else (interface_id if interface_info_dev else node_id) ) io_search_root_id = ( actual_device_id if actual_device_info else (interface_id if interface_info_dev else node_id) ) io_search_root_info = project_data.get("devices", {}).get( io_search_root_id ) # Construct Title display_name = display_info_title.get("name", display_id_title) via_node_name = node_info.get("name", node_id) title_str = f"#### {display_name}" if display_id_title != node_id: title_str += f" (via {via_node_name} @ `{node_addr}`)" else: title_str += f" (@ `{node_addr}`)" markdown_lines.append(f" - {title_str}") # Display Basic Details markdown_lines.append( f" - Address (on net): `{node_addr}`" ) type_name_disp = display_info_title.get("type_name", "N/A") order_num_disp = display_info_title.get("order_number", "N/A") pos_disp = display_info_title.get("position", "N/A") if type_name_disp and type_name_disp != "N/A": markdown_lines.append( f" - Type Name: `{type_name_disp}`" ) if order_num_disp and order_num_disp != "N/A": markdown_lines.append( f" - Order No: `{order_num_disp}`" ) if pos_disp and pos_disp != "N/A": markdown_lines.append( f" - Pos (in parent): `{pos_disp}`" ) ultimate_parent_id = rack_id if not ultimate_parent_id and actual_device_info: ultimate_parent_id = actual_device_info.get("parent_id") if ( ultimate_parent_id and ultimate_parent_id != display_id_title ): ultimate_parent_info = project_data.get("devices", {}).get( ultimate_parent_id ) ultimate_parent_name = ( ultimate_parent_info.get("name", "?") if ultimate_parent_info else "?" ) markdown_lines.append( f" - Parent Structure: `{ultimate_parent_name}`" ) # --- IO Aggregation Logic (from v24) --- aggregated_io_addresses = [] parent_structure_id = ( io_search_root_info.get("parent_id") if io_search_root_info else None ) io_search_root_name_disp = ( io_search_root_info.get("name", "?") if io_search_root_info else "?" ) if parent_structure_id: parent_structure_info = project_data.get("devices", {}).get( parent_structure_id ) parent_structure_name = ( parent_structure_info.get("name", "?") if parent_structure_info else "?" ) search_title = f"parent '{parent_structure_name}'" sibling_found_io = False for dev_scan_id, dev_scan_info in project_data.get( "devices", {} ).items(): if ( dev_scan_info.get("parent_id") == parent_structure_id ): # This dev_scan_info is the module module_context_for_sibling = { "id": dev_scan_id, "name": dev_scan_info.get("name", dev_scan_id), "order_number": dev_scan_info.get("order_number", "N/A"), "type_name": dev_scan_info.get("type_name", "N/A") } io_from_sibling = find_io_recursively( dev_scan_id, project_data, module_context_for_sibling ) if io_from_sibling: aggregated_io_addresses.extend(io_from_sibling) sibling_found_io = True if ( not sibling_found_io and not aggregated_io_addresses ): # Only show message if list still empty markdown_lines.append( f" - *No IO Addresses found in modules under {search_title} (ID: {parent_structure_id}).*" ) elif io_search_root_id: search_title = f"'{io_search_root_name_disp}'" module_context_for_root = { "id": io_search_root_id, "name": io_search_root_info.get("name", io_search_root_id), "order_number": io_search_root_info.get("order_number", "N/A"), "type_name": io_search_root_info.get("type_name", "N/A") } aggregated_io_addresses = find_io_recursively( io_search_root_id, project_data, module_context_for_root ) if not aggregated_io_addresses: markdown_lines.append( f" - *No IO Addresses found in modules under {search_title} (ID: {io_search_root_id}).*" ) else: markdown_lines.append( f" - *Could not determine structure to search for IO addresses.*" ) # --- End IO Aggregation --- # Display aggregated IO Addresses with Siemens format (Cleaned) if aggregated_io_addresses: markdown_lines.append( f" - **IO Addresses (Aggregated from Structure):**" ) sorted_agg_io = sorted( aggregated_io_addresses, key=lambda x: ( ( int(x.get("module_pos", "9999")) if str(x.get("module_pos", "9999")).isdigit() # Ensure it's a string before isdigit else 9999 ), x.get("module_name", ""), x.get("type", ""), ( int(x.get("start", "0")) if str(x.get("start", "0")).isdigit() # Ensure it's a string else float("inf") ), ), ) last_module_id_processed = None # Use the actual module ID for grouping for addr_info in sorted_agg_io: current_module_id_for_grouping = addr_info.get("module_id") if current_module_id_for_grouping != last_module_id_processed: module_name_disp = addr_info.get('module_name','?') module_type_name_disp = addr_info.get('module_type_name', 'N/A') module_order_num_disp = addr_info.get('module_order_number', 'N/A') module_line_parts = [f"**{module_name_disp}**"] if module_type_name_disp and module_type_name_disp != 'N/A': module_line_parts.append(f"Type: `{module_type_name_disp}`") if module_order_num_disp and module_order_num_disp != 'N/A': module_line_parts.append(f"OrderNo: `{module_order_num_disp}`") # Removed (Pos: ...) from this line as requested markdown_lines.append(f" - {', '.join(module_line_parts)}") last_module_id_processed = current_module_id_for_grouping # --- Siemens IO Formatting (from v25.1 - keep fixes) --- io_type = addr_info.get("type", "?") start_str = addr_info.get("start", "?") length_str = addr_info.get("length", "?") # area_str = addr_info.get("area", "?") # Not used in final output string siemens_addr = f"FMT_ERROR" # Default error length_bits = 0 try: start_byte = int(start_str) length_bits = int(length_str) length_bytes = math.ceil( length_bits / 8.0 ) # Use float division if length_bits > 0 and length_bytes == 0: length_bytes = 1 # Handle len < 8 bits end_byte = start_byte + length_bytes - 1 prefix = "P?" if io_type.lower() == "input": prefix = "EW" elif io_type.lower() == "output": prefix = "AW" siemens_addr = f"{prefix} {start_byte}..{end_byte}" except Exception: siemens_addr = ( f"FMT_ERROR({start_str},{length_str})" ) # v31: Collect data for the summary table (Corrected Indentation) current_device_io_for_table.append({ "Network": net_info.get('name', net_id), "Network Type": net_info.get('type', 'Unknown'), "Device Address": node_addr, "Device Name": display_name, # Main device name "Sub-Device": addr_info.get('module_name','?'), # Module name "Sub-Device OrderNo": addr_info.get('module_order_number', 'N/A'), "Sub-Device Type": addr_info.get('module_type_name', 'N/A'), "IO Type": io_type, "IO Address": siemens_addr, "Number of Bits": length_bits, "SortKey": ( # Add a sort key for the table net_info.get('name', net_id), sort_key((node_id, node_addr)), # Reuse the device sort key ( int(addr_info.get("module_pos", "9999")) if str(addr_info.get("module_pos", "9999")).isdigit() else 9999 ), addr_info.get("module_name", ""), io_type, ( int(addr_info.get("start", "0")) if str(addr_info.get("start", "0")).isdigit() else float("inf") ), ) }) markdown_lines.append( f" - `{siemens_addr}` (Len={length_bits} bits)" ) # --- End Siemens IO Formatting --- # IO Connections logic links_from = project_data.get("links_by_source", {}).get( display_id_title, [] ) links_to = project_data.get("links_by_target", {}).get( display_id_title, [] ) io_conns = [] for link in links_from: if "channel" in link["source_suffix"].lower(): target_str = f"{link.get('target_device_name', link['target_id'])}:{link['target_suffix']}" if link["target_id"] == display_id_title: target_str = link["target_suffix"] io_conns.append( f"`{link['source_suffix']}` → `{target_str}`" ) for link in links_to: if "channel" in link["target_suffix"].lower(): source_str = f"{link.get('source_device_name', link['source_id'])}:{link['source_suffix']}" if link["source_id"] == display_id_title: source_str = link["source_suffix"] io_conns.append( f"`{source_str}` → `{link['target_suffix']}`" ) if io_conns: markdown_lines.append( f" - **IO Connections (Channels):**" ) for conn in sorted(list(set(io_conns))): markdown_lines.append(f" - {conn}") # v31: Add collected IO for this device to the main list all_plc_io_for_table.extend(current_device_io_for_table) markdown_lines.append("") # Spacing # --- *** END Display Logic *** --- try: # Re-open the file in write mode to include the tree structure (without the table) with open(md_file_path, "w", encoding="utf-8") as f: f.write("\n".join(markdown_lines)) print(f"Markdown tree summary written to: {md_file_path}") # Generate the separate Hardware.md with the IO summary table if all_plc_io_for_table: hardware_file_path = generate_io_summary_file( all_plc_io_for_table, md_file_path, plc_name_for_title, project_data, output_root_path ) print(f"IO summary table generated in separate file: {hardware_file_path}") except Exception as e: print(f"ERROR writing Markdown file {md_file_path}: {e}") traceback.print_exc() # --- generate_io_upward_tree function (Unchanged from v23) --- def generate_io_upward_tree(project_data, md_file_path): """(v23) Generates a debug tree starting from IO addresses upwards.""" markdown_lines = ["# IO Address Upward Connection Trace (Debug v23)", ""] if not project_data or not project_data.get("devices"): markdown_lines.append("*No device data found.*") try: with open(md_file_path, "w", encoding="utf-8") as f: f.write("\n".join(markdown_lines)) print(f"\nIO upward debug tree written to: {md_file_path}") except Exception as e: print(f"ERROR writing IO upward debug tree file {md_file_path}: {e}") return node_to_network_map = {} for net_id, net_info in project_data.get("networks", {}).items(): net_name = net_info.get("name", "?") for node_id, node_addr in net_info.get("devices_on_net", {}).items(): node_to_network_map[node_id] = (net_id, net_name, node_addr) devices_with_io = [] for dev_id, dev_info in project_data.get("devices", {}).items(): if dev_info.get("io_addresses"): devices_with_io.append((dev_id, dev_info)) if not devices_with_io: markdown_lines.append("*No devices with defined IO Addresses found.*") else: markdown_lines.append( f"Found {len(devices_with_io)} device(s)/module(s) with IO addresses. Tracing connections upwards:\n" ) devices_with_io.sort( key=lambda item: ( ( int(item[1].get("position", "9999")) if item[1].get("position", "9999").isdigit() else 9999 ), item[1].get("name", ""), ) ) for dev_id, dev_info in devices_with_io: markdown_lines.append( f"## IO Module: {dev_info.get('name', dev_id)} (ID: {dev_id})" ) markdown_lines.append(f"- Position: {dev_info.get('position', 'N/A')}") markdown_lines.append("- IO Addresses:") for addr in sorted( dev_info["io_addresses"], key=lambda x: ( x.get("type", ""), ( int(x.get("start", "0")) if x.get("start", "0").isdigit() else float("inf") ), ), ): markdown_lines.append( f" - `{addr.get('type','?').ljust(6)} Start={addr.get('start','?').ljust(4)} Len={addr.get('length','?').ljust(3)}` (Area: {addr.get('area','?')})" ) markdown_lines.append("- Upward Path:") current_id = dev_id current_info = dev_info indent = " " path_found = False ancestor_limit = 15 count = 0 while current_id and count < ancestor_limit: ancestor_name = current_info.get("name", "?") if current_info else "?" ancestor_pos = ( current_info.get("position", "N/A") if current_info else "N/A" ) markdown_lines.append( f"{indent}└─ {ancestor_name} (ID: {current_id}, Pos: {ancestor_pos})" ) if current_id in node_to_network_map: net_id, net_name, node_addr = node_to_network_map[current_id] markdown_lines.append(f"{indent} └─ **Network Connection Point**") markdown_lines.append( f"{indent} - Node: {ancestor_name} (ID: {current_id})" ) markdown_lines.append( f"{indent} - Network: {net_name} (ID: {net_id})" ) markdown_lines.append(f"{indent} - Address: `{node_addr}`") plc_connection_found = False for plc_id_check, plc_info_check in project_data.get( "plcs", {} ).items(): if net_id in plc_info_check.get("connected_networks", {}): markdown_lines.append( f"{indent} - **Network associated with PLC:** {plc_info_check.get('name','?')} (ID: {plc_id_check})" ) plc_connection_found = True break if not plc_connection_found: markdown_lines.append( f"{indent} - *Network not directly associated with a known PLC in data.*" ) path_found = True break if current_id in project_data.get("plcs", {}): markdown_lines.append(f"{indent} └─ **Is PLC:** {ancestor_name}") path_found = True break parent_id = current_info.get("parent_id") if current_info else None if parent_id: current_info = project_data.get("devices", {}).get(parent_id) if not current_info: markdown_lines.append( f"{indent} └─ Parent ID {parent_id} not found. Stopping trace." ) break current_id = parent_id indent += " " else: markdown_lines.append( f"{indent} └─ Reached top level (no parent)." ) break count += 1 if count >= ancestor_limit: markdown_lines.append( f"{indent} └─ Reached ancestor limit. Stopping trace." ) if not path_found: markdown_lines.append( f"{indent}└─ *Could not trace path to a known Network Node or PLC.*" ) markdown_lines.append("") try: with open(md_file_path, "w", encoding="utf-8") as f: f.write("\n".join(markdown_lines)) print(f"\nIO upward debug tree written to: {md_file_path}") except Exception as e: print(f"ERROR writing IO upward debug tree file {md_file_path}: {e}") # --- extract_and_save_global_outputs function (Refactored from process_aml_file) --- def extract_and_save_global_outputs(aml_file_path, json_output_path, md_upward_output_path): """Extracts data from AML, saves global JSON and IO upward tree, returns project_data.""" # (Unchanged) print(f"Processing AML file: {aml_file_path}") if not os.path.exists(aml_file_path): print(f"ERROR: Input AML file not found at {aml_file_path}") return try: parser = ET.XMLParser(remove_blank_text=True, huge_tree=True) tree = ET.parse(aml_file_path, parser) root = tree.getroot() project_data = extract_aml_data(root) # v15 extraction print(f"Generating JSON output: {json_output_path}") try: with open(json_output_path, "w", encoding="utf-8") as f: json.dump(project_data, f, indent=4, default=str) print(f"JSON data written successfully.") except Exception as e: print(f"ERROR writing JSON file {json_output_path}: {e}") traceback.print_exc() # Generate and save the IO upward tree (global) generate_io_upward_tree( project_data, md_upward_output_path ) return project_data except ET.LxmlError as xml_err: print(f"ERROR parsing XML file {aml_file_path} with lxml: {xml_err}") traceback.print_exc() return None except Exception as e: print(f"ERROR processing AML file {aml_file_path}: {e}") traceback.print_exc() return None def select_cax_file(initial_dir=None): # Add initial_dir parameter """Opens a dialog to select a CAx (XML) export file, starting in the specified directory.""" root = tk.Tk() root.withdraw() file_path = filedialog.askopenfilename( title="Select CAx Export File (AML)", filetypes=[ ("AML Files", "*.aml"), ("All Files", "*.*")], # Added AML initialdir=initial_dir # Set the initial directory ) root.destroy() if not file_path: print("No CAx file selected. Exiting.") sys.exit(0) return file_path def select_output_directory(): """Opens a dialog to select the output directory.""" root = tk.Tk() root.withdraw() dir_path = filedialog.askdirectory( title="Select Output Directory for JSON and MD files" # Updated title slightly ) root.destroy() if not dir_path: print("No output directory selected. Exiting.") sys.exit(0) return dir_path def sanitize_filename(name): """Sanitizes a string to be used as a valid filename or directory name.""" name = str(name) # Ensure it's a string name = re.sub(r'[<>:"/\\|?*]', '_', name) # Replace forbidden characters name = re.sub(r'\s+', '_', name) # Replace multiple whitespace with single underscore name = name.strip('._') # Remove leading/trailing dots or underscores return name if name else "Unnamed_Device" # --- Main Execution --- if __name__ == "__main__": try: configs = load_configuration() working_directory = configs.get("working_directory") except Exception as e: print(f"Warning: Could not load configuration (frontend not running): {e}") configs = {} working_directory = None script_version = "v32.2 - Simplified IO Address Format (Separate Start/End)" # Updated version print( f"--- AML (CAx Export) to Hierarchical JSON and Obsidian MD Converter ({script_version}) ---" ) # Validate working directory with .debug fallback if not working_directory or not os.path.isdir(working_directory): print("Working directory not set or invalid in configuration.") print("Using .debug directory as fallback for direct script execution.") # Fallback to .debug directory under script location script_dir = os.path.dirname(os.path.abspath(__file__)) debug_dir = os.path.join(script_dir, ".debug") # Create .debug directory if it doesn't exist os.makedirs(debug_dir, exist_ok=True) working_directory = debug_dir print(f"Using debug directory: {working_directory}") else: print(f"Using configured working directory: {working_directory}") # Use working_directory as the output directory output_dir = working_directory print(f"Using Working Directory for Output: {output_dir}") # 1. Select Input CAx File, starting in the working directory # Pass working_directory to the selection function cax_file_path = select_cax_file(initial_dir=working_directory) # Convert paths to Path objects input_path = Path(cax_file_path) output_path = Path(output_dir) # Output path is the working directory # Check if input file exists if not input_path.is_file(): print(f"ERROR: Input file '{input_path}' not found or is not a file.") sys.exit(1) # Ensure output directory exists (redundant if working_directory is valid, but safe) output_path.mkdir(parents=True, exist_ok=True) # Construct output file paths within the selected output directory (working_directory) output_json_file = output_path / input_path.with_suffix(".hierarchical.json").name # Hardware tree MD name is now PLC-specific and handled below output_md_upward_file = output_path / input_path.with_name(f"{input_path.stem}_IO_Upward_Debug.md") print(f"Input AML: {input_path.resolve()}") print(f"Output Directory: {output_path.resolve()}") print(f"Output JSON: {output_json_file.resolve()}") print(f"Output IO Debug Tree MD: {output_md_upward_file.resolve()}") # Process the AML file to get project_data and save global files project_data = extract_and_save_global_outputs( str(input_path), str(output_json_file), str(output_md_upward_file), ) if project_data: # Now, generate the hardware tree per PLC if not project_data.get("plcs"): print("\nNo PLCs found in the project data. Cannot generate PLC-specific hardware trees.") else: print(f"\nFound {len(project_data['plcs'])} PLC(s). Generating individual hardware trees...") for plc_id, plc_data_for_plc in project_data.get("plcs", {}).items(): plc_name_original = plc_data_for_plc.get('name', plc_id) plc_name_sanitized = sanitize_filename(plc_name_original) plc_doc_dir = output_path / plc_name_sanitized / "Documentation" plc_doc_dir.mkdir(parents=True, exist_ok=True) hardware_tree_md_filename = f"{input_path.stem}_Hardware_Tree.md" output_plc_md_file = plc_doc_dir / hardware_tree_md_filename print(f" Generating Hardware Tree for PLC '{plc_name_original}' (ID: {plc_id}) at: {output_plc_md_file.resolve()}") # Pass output_path as the root directory for Hardware.md placement generate_markdown_tree(project_data, str(output_plc_md_file), plc_id, str(output_path)) # Generate Excel IO report for this PLC excel_io_filename = f"{input_path.stem}_IO_Report.xlsx" output_excel_file = plc_doc_dir / excel_io_filename print(f" Generating Excel IO Report for PLC '{plc_name_original}' (ID: {plc_id}) at: {output_excel_file.resolve()}") generate_io_excel_report(project_data, str(output_excel_file), plc_id, str(output_path)) else: print("\nFailed to process AML data. Halting before generating PLC-specific trees.") print("\nScript finished.")