#!/usr/bin/env python3 """ M12DR Module Boilerplate Model ============================== Model for M12DR (5032-8IOLM12DR/A) modules with support for different configurations. Supports PalletBuildMaster and D2CMaster variants. """ from typing import Dict, Optional, TYPE_CHECKING import xml.etree.ElementTree as ET from dataclasses import dataclass from datetime import datetime import os import re if TYPE_CHECKING: from excel_data_processor import ModuleData, IOPathMapping @dataclass class M12DRModuleConfig: """Configuration for an M12DR module instance.""" name: str # Module name (e.g., "PalletBuildMaster1") variant: str # Module variant: "PalletBuildMaster" or "D2CMaster" ip_address: str = "192.168.1.1" parent_module: str = "SLOT1_EN4TR" parent_port_id: str = "2" inhibited: bool = False major_fault: bool = False input_comments: Optional[Dict[str, str]] = None # Key: operand (e.g., ".IOLINK00"), Value: comment output_comments: Optional[Dict[str, str]] = None # Key: operand (e.g., ".PT07"), Value: comment class M12DRModuleGenerator: """Generator for M12DR module XML with different variant support.""" # Mapping of variants to default boilerplate filenames (fallback) VARIANT_BOILERPLATE_MAP = { "PalletBuildMaster": "PalletBuildMaster_Module.L5X", "D2CMaster": "D2CMaster_Module.L5X", "PDP_FIO": "PDP_FIO_Module.L5X", #"UL_FIO": "PDP_FIO_Module.L5X", # Fallback to PDP_FIO if specific UL boilerplate not found #"FIO_GENERIC": "PDP_FIO_Module.L5X" # Fallback to PDP_FIO for generic FIO modules } def __init__(self, config: M12DRModuleConfig): self.config = config # Determine the correct boilerplate file self.boilerplate_filename = self._determine_boilerplate_filename() self.boilerplate_path = os.path.join("boilerplate", self.boilerplate_filename) self.tree = None self.root = None # Cache for operand patterns extracted from boilerplate self._operand_patterns = None def _determine_boilerplate_filename(self) -> str: """Determine the boilerplate filename to use. Priority: 1. Check for module-specific boilerplate: {module_name}_Module.L5X 2. Fall back to variant-based boilerplate """ # First, try module-specific boilerplate module_specific_filename = f"{self.config.name}_Module.L5X" module_specific_path = os.path.join("boilerplate", module_specific_filename) if os.path.exists(module_specific_path): print(f" {self.config.name} (FIO {self.config.variant}): Using module-specific boilerplate {module_specific_filename}") return module_specific_filename # Fall back to variant-based boilerplate if self.config.variant not in self.VARIANT_BOILERPLATE_MAP: raise ValueError(f"Unsupported variant: {self.config.variant}. Supported variants: {list(self.VARIANT_BOILERPLATE_MAP.keys())}") fallback_filename = self.VARIANT_BOILERPLATE_MAP[self.config.variant] print(f" {self.config.name} (FIO {self.config.variant}): Using variant boilerplate {fallback_filename}") return fallback_filename def load_boilerplate(self): """Load the appropriate boilerplate template based on variant.""" if not os.path.exists(self.boilerplate_path): raise FileNotFoundError(f"Boilerplate file not found: {self.boilerplate_path}") self.tree = ET.parse(self.boilerplate_path) self.root = self.tree.getroot() def _extract_operand_patterns(self) -> Dict[str, str]: """Extract operand patterns from the boilerplate XML structure. Returns dict mapping terminal number to operand format: e.g., {"04": ".PT04.DATA", "00": ".IOLINK00"} """ if self._operand_patterns is not None: return self._operand_patterns patterns = {} # Find InputTag and OutputTag structures input_tag = self.root.find(".//Connection[@Name='Data']/InputTag") output_tag = self.root.find(".//Connection[@Name='Data']/OutputTag") # Get variant-specific formatting rules use_data_suffix = self._should_use_data_suffix() # Process InputTag structure if input_tag is not None: structure = input_tag.find(".//Structure") if structure is not None: self._extract_patterns_from_structure(structure, patterns, use_data_suffix) # Process OutputTag structure if output_tag is not None: structure = output_tag.find(".//Structure") if structure is not None: self._extract_patterns_from_structure(structure, patterns, use_data_suffix) self._operand_patterns = patterns return patterns def _should_use_data_suffix(self) -> bool: """Determine if this variant should use .DATA suffix for digital I/O based on variant type.""" # PDP_FIO uses .DATA suffix for digital I/O return True def _extract_patterns_from_structure(self, structure: ET.Element, patterns: Dict[str, str], use_data_suffix: bool): """Extract operand patterns from a Structure element.""" for member in structure.findall("StructureMember"): name = member.get("Name") data_type = member.get("DataType") if not name or not data_type: continue # Handle IOLink channels if name.startswith("IOLink") and "IOL" in data_type: # Extract number from IOLink00, IOLink02, etc. number_match = re.search(r'IOLink(\d+)', name) if number_match: number = number_match.group(1) patterns[number] = f".IOLINK{number}" # Handle Point channels (digital I/O) elif name.startswith("Pt") and ("DI" in data_type or "DO" in data_type): # Extract number from Pt04, Pt05, etc. number_match = re.search(r'Pt(\d+)', name) if number_match: number = number_match.group(1) if use_data_suffix: patterns[number] = f".PT{number}.DATA" else: patterns[number] = f".PT{number}" def _convert_terminal_to_operand_dynamic(self, terminal: str, signal: str) -> str: """Convert terminal and signal to operand using patterns from boilerplate. Args: terminal: Terminal like "IO4", "IO00", etc. signal: Signal type like "I", "O", "IOLINK", "SPARE" Returns: Operand string like ".PT04.DATA", ".IOLINK00", etc. """ if not terminal or not signal: return "" # Get operand patterns from boilerplate patterns = self._extract_operand_patterns() terminal = terminal.upper().strip() signal = signal.upper().strip() # Extract number from terminal (IO4 -> 04, IO00 -> 00, etc.) if terminal.startswith("IO"): try: terminal_num = terminal[2:] # Remove "IO" prefix # Pad to 2 digits if needed terminal_num = terminal_num.zfill(2) # For IOLINK signals, look for IOLINK pattern if signal == "IOLINK": iolink_operand = f".IOLINK{terminal_num}" if terminal_num in patterns and patterns[terminal_num] == iolink_operand: return iolink_operand # For digital I/O signals, look for PT pattern if signal in ("I", "O", "SPARE"): # Check if we have a pattern for this terminal if terminal_num in patterns: return patterns[terminal_num] # Fallback to .PT format if no pattern found return f".PT{terminal_num}.DATA" except (ValueError, IndexError): return "" # Handle direct operand format (starts with .) if terminal.startswith('.'): return terminal return "" def update_module_name(self): """Update the module name throughout the XML.""" # Update in root attributes self.root.set("TargetName", self.config.name) # Update Module element module = self.root.find(".//Module[@Use='Target']") if module is not None: module.set("Name", self.config.name) def update_ip_address(self): """Update the IP address in the Ethernet port.""" port = self.root.find(".//Port[@Type='Ethernet']") if port is not None: port.set("Address", self.config.ip_address) def update_parent_module(self): """Update parent module references.""" module = self.root.find(".//Module[@Use='Target']") if module is not None: module.set("ParentModule", self.config.parent_module) module.set("ParentModPortId", self.config.parent_port_id) def update_input_comments(self): """Update input tag comments.""" input_tag = self.root.find(".//Connection[@Name='Data']/InputTag") if input_tag is not None and self.config.input_comments: # Find or create Comments section comments_section = input_tag.find("Comments") if comments_section is None: # Create Comments section as the first child comments_section = ET.Element("Comments") input_tag.insert(0, comments_section) else: # Clear existing comments comments_section.clear() # Add new comments for operand, comment_text in self.config.input_comments.items(): comment = ET.SubElement(comments_section, "Comment") comment.set("Operand", operand) comment.text = comment_text def update_output_comments(self): """Update output tag comments.""" output_tag = self.root.find(".//Connection[@Name='Data']/OutputTag") if output_tag is not None and self.config.output_comments: # Find or create Comments section comments_section = output_tag.find("Comments") if comments_section is None: # Create Comments section as the first child comments_section = ET.Element("Comments") output_tag.insert(0, comments_section) else: # Clear existing comments comments_section.clear() # Add new comments for operand, comment_text in self.config.output_comments.items(): comment = ET.SubElement(comments_section, "Comment") comment.set("Operand", operand) comment.text = comment_text def update_export_date(self): """Update the export date to current time.""" export_date = datetime.now().strftime("%a %b %d %H:%M:%S %Y") self.root.set("ExportDate", export_date) def apply_updates(self): """Apply all updates to the boilerplate.""" self.update_module_name() self.update_ip_address() self.update_parent_module() self.update_input_comments() self.update_output_comments() self.update_export_date() def save(self, output_path: str): """Save the updated module to file with proper formatting.""" if self.tree is None: raise RuntimeError("No boilerplate loaded. Call load_boilerplate() first.") # Add proper indentation to the XML self._indent_xml(self.root) # Save with proper formatting self.tree.write(output_path, encoding='UTF-8', xml_declaration=True) def _indent_xml(self, elem, level=0): """Add proper indentation to XML elements for readable output.""" indent = "\n" + level * " " if len(elem): if not elem.text or not elem.text.strip(): elem.text = indent + " " if not elem.tail or not elem.tail.strip(): elem.tail = indent for child in elem: self._indent_xml(child, level + 1) if not child.tail or not child.tail.strip(): child.tail = indent else: if level and (not elem.tail or not elem.tail.strip()): elem.tail = indent def get_xml_string(self) -> str: """Get the XML as a string.""" if self.tree is None: raise RuntimeError("No boilerplate loaded. Call load_boilerplate() first.") return ET.tostring(self.root, encoding='unicode') # ------------------------------------------------------------------ # High-level helper for generator refactor # ------------------------------------------------------------------ @classmethod def from_excel( cls, module_data: "ModuleData", *, parent_module: str = "SLOT2_EN4TR", parent_port_id: str = "2", ) -> "M12DRModuleGenerator": """Create, configure, and return a generator using only Excel data. The calling code can then directly access ``generator.root`` or save the file. It fully replaces the manual logic previously present in EnhancedMCMGenerator._add_iolm_modules. """ variant = _determine_variant(module_data) # Create generator to access operand patterns config = M12DRModuleConfig( name=module_data.tagname, variant=variant, ip_address=module_data.ip_address or "192.168.1.1", parent_module=parent_module, parent_port_id=parent_port_id, ) gen = cls(config) gen.load_boilerplate() # Now process comments using dynamic operand conversion input_comments: Dict[str, str] = {} output_comments: Dict[str, str] = {} for m in module_data.io_mappings: operand = gen._convert_terminal_to_operand_dynamic(m.terminal, m.signal) if not operand: continue comment_text = "SPARE" if m.description and m.description.upper() == "SPARE" else m.description if not comment_text: continue if _is_output_signal(m.signal, m.io_path): output_comments[operand] = comment_text else: input_comments[operand] = comment_text # Update config with processed comments gen.config.input_comments = input_comments if input_comments else None gen.config.output_comments = output_comments if output_comments else None gen.apply_updates() return gen def create_m12dr_module(name: str, variant: str, ip_address: str = "192.168.1.1", parent_module: str = "SLOT1_EN4TR", parent_port_id: str = "2", input_comments: Optional[Dict[str, str]] = None, output_comments: Optional[Dict[str, str]] = None) -> M12DRModuleConfig: """Factory function to create an M12DR module configuration. Note: input_comments and output_comments default to None (no comments). Use the get_*_default_*_comments() helper functions if you want default templates. """ return M12DRModuleConfig( name=name, variant=variant, ip_address=ip_address, parent_module=parent_module, parent_port_id=parent_port_id, input_comments=input_comments, output_comments=output_comments ) # Helper functions to get default comment structures for PDP_FIO variant def get_pdp_fio_default_input_comments() -> Dict[str, str]: """Get default input comments for PDP_FIO variant.""" return { ".PT00.DATA": "Input 1", ".PT01.DATA": "Input 2", ".PT02.DATA": "Input 3", ".PT03.DATA": "Input 4", ".PT04.DATA": "Input 5", ".PT06.DATA": "Input 6", ".PT08.DATA": "Input 7", ".PT09.DATA": "Input 8", ".PT10.DATA": "Input 9", ".PT11.DATA": "Input 10", ".PT12.DATA": "Input 11", ".IOLINK14": "Smart Device" } def get_pdp_fio_default_output_comments() -> Dict[str, str]: """Get default output comments for PDP_FIO variant.""" return { ".PT05.DATA": "Output 1", ".PT07.DATA": "Output 2", ".PT13.DATA": "Output 3" } # -------------------------------------------------------------------------------------- # Utility helpers (ported from EnhancedMCMGenerator for 100 % behaviour parity) # -------------------------------------------------------------------------------------- def _determine_variant(module_data: "ModuleData") -> str: """Determine M12DR variant based on module name and FIOH patterns. Logic: 1. If module name contains "PDP", use PDP_FIO variant 2. If FIOH is found in IO4 or IO12 descriptions, use PalletBuildMaster 3. Otherwise, use D2CMaster """ # Check if module name contains PDP if "PDP" in module_data.tagname.upper(): return "PDP_FIO" # Check for FIOH in IO4 or IO12 descriptions terminal_desc: Dict[str, str] = {} for m in module_data.io_mappings: if m.terminal and m.description: terminal_desc[m.terminal.upper()] = m.description.upper() io12 = terminal_desc.get("IO12", "") io4 = terminal_desc.get("IO4", "") # If any of the IO4/IO12 terminals contains FIOH, then choose PalletBuildMaster if any("FIOH" in t for t in (io12, io4)): return "PalletBuildMaster" # Default to D2CMaster for all other cases return "D2CMaster" def _is_output_signal(signal: str, io_path: str) -> bool: if not signal: return False s = signal.upper().strip() if s == "O": return True if s in ("I", "IOLINK"): return False if io_path and ":O." in io_path.upper(): return True return False # -------------------------------------------------------------------------------------- # Example usage if __name__ == "__main__": # Example: Create a PDP_FIO module pdp_fio_config = create_m12dr_module( name="PDP_FIO1", variant="PDP_FIO", ip_address="123.121.231.231", input_comments={ ".PT00.DATA": "Emergency Stop", ".PT01.DATA": "Start Button", ".PT02.DATA": "Reset Button", ".IOLINK14": "Smart Sensor" }, output_comments={ ".PT05.DATA": "Status Light", ".PT07.DATA": "Warning Light", ".PT13.DATA": "Alarm Horn" } ) generator = M12DRModuleGenerator(pdp_fio_config) generator.load_boilerplate() generator.apply_updates() generator.save("generated/PDP_FIO1.L5X") print(f"Generated PDP_FIO module: {pdp_fio_config.name}")