246 lines
9.3 KiB
Python
246 lines
9.3 KiB
Python
|
|
"""
|
||
|
|
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
|