""" Modbus TCP Server for Calejo Control Adapter. Provides Modbus TCP interface for SCADA systems to access setpoints and status. """ import asyncio from typing import Dict, Optional from datetime import datetime import structlog from pymodbus.server import StartAsyncTcpServer from pymodbus.datastore import ModbusSequentialDataBlock from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext from pymodbus.transaction import ModbusSocketFramer from src.core.setpoint_manager import SetpointManager logger = structlog.get_logger() class ModbusServer: """Modbus TCP Server for Calejo Control Adapter.""" def __init__( self, setpoint_manager: SetpointManager, host: str = "0.0.0.0", port: int = 502, unit_id: int = 1 ): self.setpoint_manager = setpoint_manager self.host = host self.port = port self.unit_id = unit_id self.server = None self.context = None # Memory mapping self.holding_registers = None self.input_registers = None self.coils = None # Register mapping configuration self.REGISTER_CONFIG = { 'SETPOINT_BASE': 0, # Holding register 0-99: Setpoints (Hz * 10) 'STATUS_BASE': 100, # Input register 100-199: Status codes 'SAFETY_BASE': 200, # Input register 200-299: Safety status 'EMERGENCY_STOP_COIL': 0, # Coil 0: Emergency stop status 'FAILSAFE_COIL': 1, # Coil 1: Failsafe mode status } # Pump address mapping self.pump_addresses = {} # (station_id, pump_id) -> register_offset async def start(self): """Start the Modbus TCP server.""" try: # Initialize data blocks await self._initialize_datastore() # Start server self.server = await StartAsyncTcpServer( context=self.context, framer=ModbusSocketFramer, address=(self.host, self.port), defer_start=False ) logger.info( "modbus_server_started", host=self.host, port=self.port, unit_id=self.unit_id ) # Start background task to update registers asyncio.create_task(self._update_registers_loop()) except Exception as e: logger.error("failed_to_start_modbus_server", error=str(e)) raise async def stop(self): """Stop the Modbus TCP server.""" if self.server: # Note: pymodbus doesn't have a direct stop method # We'll rely on the task being cancelled logger.info("modbus_server_stopping") async def _initialize_datastore(self): """Initialize the Modbus data store.""" # Initialize data blocks # Holding registers (read/write): Setpoints self.holding_registers = ModbusSequentialDataBlock( self.REGISTER_CONFIG['SETPOINT_BASE'], [0] * 100 # 100 registers for setpoints ) # Input registers (read-only): Status and safety self.input_registers = ModbusSequentialDataBlock( self.REGISTER_CONFIG['STATUS_BASE'], [0] * 200 # 200 registers for status ) # Coils (read-only): Binary status self.coils = ModbusSequentialDataBlock( self.REGISTER_CONFIG['EMERGENCY_STOP_COIL'], [False] * 10 # 10 coils for binary status ) # Create slave context store = ModbusSlaveContext( hr=self.holding_registers, # Holding registers ir=self.input_registers, # Input registers co=self.coils, # Coils zero_mode=True ) # Create server context self.context = ModbusServerContext(slaves=store, single=True) # Initialize pump address mapping await self._initialize_pump_mapping() async def _initialize_pump_mapping(self): """Initialize mapping between pumps and Modbus addresses.""" stations = self.setpoint_manager.discovery.get_stations() address_counter = 0 for station in stations: station_id = station['station_id'] pumps = self.setpoint_manager.discovery.get_pumps(station_id) for pump in pumps: pump_id = pump['pump_id'] # Assign register addresses self.pump_addresses[(station_id, pump_id)] = { 'setpoint_register': address_counter, 'status_register': address_counter + self.REGISTER_CONFIG['STATUS_BASE'], 'safety_register': address_counter + self.REGISTER_CONFIG['SAFETY_BASE'] } address_counter += 1 # Don't exceed available registers if address_counter >= 100: logger.warning("modbus_register_limit_reached") break async def _update_registers_loop(self): """Background task to update Modbus registers periodically.""" while True: try: await self._update_registers() await asyncio.sleep(5) # Update every 5 seconds except Exception as e: logger.error("failed_to_update_registers", error=str(e)) await asyncio.sleep(10) # Wait longer on error async def _update_registers(self): """Update all Modbus register values.""" # Update pump setpoints and status for (station_id, pump_id), addresses in self.pump_addresses.items(): try: # Get current setpoint setpoint = self.setpoint_manager.get_current_setpoint(station_id, pump_id) if setpoint is not None: # Convert setpoint to integer (Hz * 10 for precision) setpoint_int = int(setpoint * 10) # Update holding register (setpoint) self.holding_registers.setValues( addresses['setpoint_register'], [setpoint_int] ) # Determine status code status_code = 0 # Normal operation safety_code = 0 # Normal safety if self.setpoint_manager.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id): status_code = 2 # Emergency stop safety_code = 1 elif self.setpoint_manager.watchdog.is_failsafe_active(station_id, pump_id): status_code = 1 # Failsafe mode safety_code = 2 # Update input registers (status and safety) self.input_registers.setValues( addresses['status_register'], [status_code] ) self.input_registers.setValues( addresses['safety_register'], [safety_code] ) except Exception as e: logger.error( "failed_to_update_pump_registers", station_id=station_id, pump_id=pump_id, error=str(e) ) # Update global status coils try: # Check if any emergency stops are active any_emergency_stop = ( self.setpoint_manager.emergency_stop_manager.system_emergency_stop or len(self.setpoint_manager.emergency_stop_manager.emergency_stop_stations) > 0 or len(self.setpoint_manager.emergency_stop_manager.emergency_stop_pumps) > 0 ) # Check if any failsafe modes are active any_failsafe = any( self.setpoint_manager.watchdog.is_failsafe_active(station_id, pump_id) for (station_id, pump_id) in self.pump_addresses.keys() ) # Update coils self.coils.setValues( self.REGISTER_CONFIG['EMERGENCY_STOP_COIL'], [any_emergency_stop] ) self.coils.setValues( self.REGISTER_CONFIG['FAILSAFE_COIL'], [any_failsafe] ) except Exception as e: logger.error("failed_to_update_status_coils", error=str(e)) def get_pump_setpoint_address(self, station_id: str, pump_id: str) -> Optional[int]: """Get Modbus register address for a pump's setpoint.""" addresses = self.pump_addresses.get((station_id, pump_id)) return addresses['setpoint_register'] if addresses else None def get_pump_status_address(self, station_id: str, pump_id: str) -> Optional[int]: """Get Modbus register address for a pump's status.""" addresses = self.pump_addresses.get((station_id, pump_id)) return addresses['status_register'] if addresses else None