""" OPC UA Server for Calejo Control Adapter. Provides OPC UA interface for SCADA systems to access setpoints and status. """ import asyncio from typing import Dict, Optional from datetime import datetime import structlog from asyncua import Server, Node from asyncua.common.methods import uamethod from src.core.setpoint_manager import SetpointManager logger = structlog.get_logger() class OPCUAServer: """OPC UA Server for Calejo Control Adapter.""" def __init__( self, setpoint_manager: SetpointManager, endpoint: str = "opc.tcp://0.0.0.0:4840", server_name: str = "Calejo Control OPC UA Server" ): self.setpoint_manager = setpoint_manager self.endpoint = endpoint self.server_name = server_name self.server = None self.namespace_idx = None # Node references self.objects_node = None self.station_nodes = {} self.pump_nodes = {} async def start(self): """Start the OPC UA server.""" try: # Create server self.server = Server() await self.server.init() # Configure server self.server.set_endpoint(self.endpoint) self.server.set_server_name(self.server_name) self.server.set_security_policy([ "http://opcfoundation.org/UA/SecurityPolicy#None" ]) # Setup namespace uri = "http://calejo-control.com/OPCUA/" self.namespace_idx = await self.server.register_namespace(uri) # Create object structure await self._create_object_structure() # Start server await self.server.start() logger.info( "opcua_server_started", endpoint=self.endpoint, namespace_idx=self.namespace_idx ) # Start background task to update setpoints asyncio.create_task(self._update_setpoints_loop()) except Exception as e: logger.error("failed_to_start_opcua_server", error=str(e)) raise async def stop(self): """Stop the OPC UA server.""" if self.server: await self.server.stop() logger.info("opcua_server_stopped") async def _create_object_structure(self): """Create the OPC UA object structure.""" # Get objects node self.objects_node = self.server.get_objects_node() # Create Calejo Control folder calejo_folder = await self.objects_node.add_folder( self.namespace_idx, "CalejoControl" ) # Create stations and pumps structure stations = self.setpoint_manager.discovery.get_stations() for station in stations: station_id = station['station_id'] # Create station folder station_folder = await calejo_folder.add_folder( self.namespace_idx, f"Station_{station_id}" ) # Add station info variables station_name_var = await station_folder.add_variable( self.namespace_idx, "StationName", station.get('station_name', station_id) ) await station_name_var.set_writable(False) # Create pumps for this station pumps = self.setpoint_manager.discovery.get_pumps(station_id) for pump in pumps: pump_id = pump['pump_id'] # Create pump object pump_obj = await station_folder.add_object( self.namespace_idx, f"Pump_{pump_id}" ) # Add pump variables pump_name_var = await pump_obj.add_variable( self.namespace_idx, "PumpName", pump.get('pump_name', pump_id) ) await pump_name_var.set_writable(False) control_type_var = await pump_obj.add_variable( self.namespace_idx, "ControlType", pump.get('control_type', 'UNKNOWN') ) await control_type_var.set_writable(False) # Add setpoint variable (writable for SCADA override) setpoint_var = await pump_obj.add_variable( self.namespace_idx, "Setpoint_Hz", 0.0 ) await setpoint_var.set_writable(True) # Add safety status variable safety_status_var = await pump_obj.add_variable( self.namespace_idx, "SafetyStatus", "normal" ) await safety_status_var.set_writable(False) # Add timestamp variable timestamp_var = await pump_obj.add_variable( self.namespace_idx, "LastUpdate", datetime.now().isoformat() ) await timestamp_var.set_writable(False) # Store node references self.pump_nodes[(station_id, pump_id)] = { 'object': pump_obj, 'setpoint': setpoint_var, 'safety_status': safety_status_var, 'timestamp': timestamp_var } self.station_nodes[station_id] = station_folder # Add server status variables server_status_folder = await calejo_folder.add_folder( self.namespace_idx, "ServerStatus" ) server_status_var = await server_status_folder.add_variable( self.namespace_idx, "Status", "running" ) await server_status_var.set_writable(False) uptime_var = await server_status_folder.add_variable( self.namespace_idx, "Uptime", 0 ) await uptime_var.set_writable(False) total_pumps_var = await server_status_folder.add_variable( self.namespace_idx, "TotalPumps", len(self.pump_nodes) ) await total_pumps_var.set_writable(False) async def _update_setpoints_loop(self): """Background task to update setpoints periodically.""" while True: try: await self._update_setpoints() await asyncio.sleep(5) # Update every 5 seconds except Exception as e: logger.error("failed_to_update_setpoints", error=str(e)) await asyncio.sleep(10) # Wait longer on error async def _update_setpoints(self): """Update all setpoint values in OPC UA server.""" for (station_id, pump_id), nodes in self.pump_nodes.items(): try: # Get current setpoint setpoint = self.setpoint_manager.get_current_setpoint(station_id, pump_id) if setpoint is not None: # Update setpoint variable await nodes['setpoint'].write_value(float(setpoint)) # Update safety status safety_status = "normal" if self.setpoint_manager.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id): safety_status = "emergency_stop" elif self.setpoint_manager.watchdog.is_failsafe_active(station_id, pump_id): safety_status = "failsafe" await nodes['safety_status'].write_value(safety_status) # Update timestamp await nodes['timestamp'].write_value(datetime.now().isoformat()) except Exception as e: logger.error( "failed_to_update_pump_setpoint", station_id=station_id, pump_id=pump_id, error=str(e) )