#!/usr/bin/env python3 """ Festo Solenoid Module Boilerplate Model ====================================== Model for Festo Solenoid modules (VAEM-L1-S-8). Supports configuring module name, parent module, port ID (should be even for M12DR masters), port address, and basic settings. """ from typing import Dict import xml.etree.ElementTree as ET from dataclasses import dataclass from datetime import datetime import os @dataclass class FestoSolenoidConfig: """Configuration for a Festo solenoid module instance.""" name: str # Module name (e.g., "UL11_13_SOL1") parent_module: str = "Master" # Parent IO-Link master module parent_port_id: str = "4" # Port on the IO-Link master (should be even for M12DR: 2,4,6,8) port_address: str = "0" # IO-Link port address inhibited: bool = False major_fault: bool = False # Note: For M12DR masters, parent_port_id must be even (2,4,6,8) class FestoSolenoidGenerator: """Generator for Festo solenoid module XML.""" def __init__(self, config: FestoSolenoidConfig): self.config = config self.boilerplate_path = os.path.join("boilerplate", "Festo_Solenoids_Module.L5X") self.tree = None self.root = None def load_boilerplate(self): """Load the Festo solenoid boilerplate template.""" 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 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_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_port_address(self): """Update the IO-Link port address.""" port = self.root.find(".//Port[@Type='IO-Link']") if port is not None: port.set("Address", self.config.port_address) def update_inhibited_status(self): """Update the inhibited and major fault status.""" module = self.root.find(".//Module[@Use='Target']") if module is not None: module.set("Inhibited", str(self.config.inhibited).lower()) module.set("MajorFault", str(self.config.major_fault).lower()) 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_parent_module() self.update_port_address() self.update_inhibited_status() self.update_export_date() def save(self, output_path: str): """Save the updated module to file.""" if self.tree is None: raise RuntimeError("No boilerplate loaded. Call load_boilerplate() first.") # Save with proper formatting and preserve CDATA sections xml_string = ET.tostring(self.root, encoding='unicode') # Fix CDATA wrapper for L5K data - ElementTree strips CDATA sections import re # Pattern to find L5K data that needs CDATA wrapper l5k_pattern = r'()(\s*\[.*?\]|\s*\(.*?\))\s*()' def replace_with_cdata(match): opening_tag = match.group(1) data_content = match.group(2).strip() closing_tag = match.group(3) # Add proper indentation and line breaks return f'{opening_tag}\n\n{closing_tag}' # Apply CDATA wrapper to L5K data xml_string = re.sub(l5k_pattern, replace_with_cdata, xml_string, flags=re.DOTALL | re.MULTILINE) # Write the corrected XML with open(output_path, 'w', encoding='utf-8') as f: f.write('\n') f.write(xml_string) 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') # ------------------------------------------------------------------ # Convenience helper used by EnhancedMCMGenerator's factory dispatch. # ------------------------------------------------------------------ @classmethod def from_mapping(cls, mapping: Dict[str, str]) -> "FestoSolenoidGenerator": """Create and fully configure a solenoid generator from the Excel-derived entry (a plain dict). The structure expected is the one produced in EnhancedMCMGenerator._organize_modules_by_type().""" cfg = FestoSolenoidConfig( name=mapping["name"], parent_module=mapping["parent_module"], parent_port_id=mapping["parent_port_id"], port_address=mapping["port_address"], ) # Optional: Validate even port for M12DR if "M12DR" in mapping.get("model", "") and int(cfg.parent_port_id) % 2 != 0: raise ValueError(f"Festo solenoid must connect to even port on M12DR, got {cfg.parent_port_id}") gen = cls(cfg) gen.load_boilerplate() gen.apply_updates() return gen def create_festo_solenoid(name: str, parent_module: str = "Master", parent_port_id: str = "4", port_address: str = "0") -> FestoSolenoidConfig: """Factory function to create a Festo solenoid configuration.""" return FestoSolenoidConfig( name=name, parent_module=parent_module, parent_port_id=parent_port_id, port_address=port_address, ) # Example usage if __name__ == "__main__": # Example: Create a Festo solenoid with custom configuration config = create_festo_solenoid( name="UL11_13_SOL1", parent_module="UL11_13_FIO1", parent_port_id="4", port_address="0", ) generator = FestoSolenoidGenerator(config) generator.load_boilerplate() generator.apply_updates() generator.save("generated/UL11_13_SOL1.L5X") print(f"Generated Festo solenoid module: {config.name}") print(f"Parent module: {config.parent_module}") print(f"Port: {config.parent_port_id}") print(f"Address: {config.port_address}")