""" Dashboard Configuration Manager Provides comprehensive SCADA and hardware configuration through the dashboard """ import json import logging from typing import Dict, List, Optional, Any from pydantic import BaseModel, validator from enum import Enum logger = logging.getLogger(__name__) class ProtocolType(str, Enum): OPC_UA = "opcua" MODBUS_TCP = "modbus_tcp" MODBUS_RTU = "modbus_rtu" REST_API = "rest_api" class SCADAProtocolConfig(BaseModel): """Base SCADA protocol configuration""" protocol_type: ProtocolType enabled: bool = True name: str description: str = "" class OPCUAConfig(SCADAProtocolConfig): """OPC UA protocol configuration""" protocol_type: ProtocolType = ProtocolType.OPC_UA endpoint: str = "opc.tcp://0.0.0.0:4840" security_policy: str = "Basic256Sha256" certificate_file: str = "/app/certs/server.pem" private_key_file: str = "/app/certs/server.key" @validator('endpoint') def validate_endpoint(cls, v): if not v.startswith("opc.tcp://"): raise ValueError("OPC UA endpoint must start with 'opc.tcp://'") return v class ModbusTCPConfig(SCADAProtocolConfig): """Modbus TCP protocol configuration""" protocol_type: ProtocolType = ProtocolType.MODBUS_TCP host: str = "0.0.0.0" port: int = 502 unit_id: int = 1 timeout: float = 5.0 @validator('port') def validate_port(cls, v): if not 1 <= v <= 65535: raise ValueError("Port must be between 1 and 65535") return v class PumpStationConfig(BaseModel): """Pump station configuration""" station_id: str name: str location: str = "" description: str = "" max_pumps: int = 4 power_capacity: float = 150.0 flow_capacity: float = 500.0 @validator('station_id') def validate_station_id(cls, v): if not v.replace('_', '').isalnum(): raise ValueError("Station ID must be alphanumeric with underscores") return v class PumpConfig(BaseModel): """Individual pump configuration""" pump_id: str station_id: str name: str type: str = "centrifugal" # centrifugal, submersible, etc. power_rating: float # kW max_speed: float # Hz min_speed: float # Hz vfd_model: str = "" manufacturer: str = "" serial_number: str = "" @validator('pump_id') def validate_pump_id(cls, v): if not v.replace('_', '').isalnum(): raise ValueError("Pump ID must be alphanumeric with underscores") return v class SafetyLimitsConfig(BaseModel): """Safety limits configuration""" station_id: str pump_id: str hard_min_speed_hz: float = 20.0 hard_max_speed_hz: float = 50.0 hard_min_level_m: Optional[float] = None hard_max_level_m: Optional[float] = None hard_max_power_kw: Optional[float] = None max_speed_change_hz_per_min: float = 30.0 @validator('hard_max_speed_hz') def validate_speed_limits(cls, v, values): if 'hard_min_speed_hz' in values and v <= values['hard_min_speed_hz']: raise ValueError("Maximum speed must be greater than minimum speed") return v class DataPointMapping(BaseModel): """Data point mapping between protocol and internal representation""" protocol_type: ProtocolType station_id: str pump_id: str data_type: str # setpoint, actual_speed, status, etc. protocol_address: str # OPC UA node, Modbus register, etc. data_type_specific: Dict[str, Any] = {} class ProtocolMapping(BaseModel): """Unified protocol mapping configuration for all protocols""" id: str protocol_type: ProtocolType station_id: str pump_id: str data_type: str # setpoint, status, power, flow, level, safety, etc. protocol_address: str # register address or OPC UA node db_source: str # database table and column transformation_rules: List[Dict[str, Any]] = [] # Protocol-specific configurations modbus_config: Optional[Dict[str, Any]] = None opcua_config: Optional[Dict[str, Any]] = None @validator('id') def validate_id(cls, v): if not v.replace('_', '').isalnum(): raise ValueError("Mapping ID must be alphanumeric with underscores") return v @validator('protocol_address') def validate_protocol_address(cls, v, values): if 'protocol_type' in values: if values['protocol_type'] == ProtocolType.MODBUS_TCP: try: address = int(v) if not (0 <= address <= 65535): raise ValueError("Modbus address must be between 0 and 65535") except ValueError: raise ValueError("Modbus address must be a valid integer") elif values['protocol_type'] == ProtocolType.MODBUS_RTU: try: address = int(v) if not (0 <= address <= 65535): raise ValueError("Modbus RTU address must be between 0 and 65535") except ValueError: raise ValueError("Modbus RTU address must be a valid integer") elif values['protocol_type'] == ProtocolType.OPC_UA: if not v.startswith('ns='): raise ValueError("OPC UA Node ID must start with 'ns='") elif values['protocol_type'] == ProtocolType.REST_API: if not v.startswith(('http://', 'https://')): raise ValueError("REST API endpoint must start with 'http://' or 'https://'") return v class HardwareDiscoveryResult(BaseModel): """Result from hardware auto-discovery""" success: bool discovered_stations: List[PumpStationConfig] = [] discovered_pumps: List[PumpConfig] = [] errors: List[str] = [] warnings: List[str] = [] class ConfigurationManager: """Manages comprehensive system configuration through dashboard""" def __init__(self, db_client=None): self.protocol_configs: Dict[ProtocolType, SCADAProtocolConfig] = {} self.stations: Dict[str, PumpStationConfig] = {} self.pumps: Dict[str, PumpConfig] = {} self.safety_limits: Dict[str, SafetyLimitsConfig] = {} self.data_mappings: List[DataPointMapping] = [] self.protocol_mappings: List[ProtocolMapping] = [] self.db_client = db_client # Load mappings from database if available if self.db_client: self._load_mappings_from_db() def _load_mappings_from_db(self): """Load protocol mappings from database""" try: query = """ SELECT mapping_id, station_id, pump_id, protocol_type, protocol_address, data_type, db_source, enabled FROM protocol_mappings WHERE enabled = true ORDER BY station_id, pump_id, protocol_type """ results = self.db_client.execute_query(query) logger.info(f"Database query returned {len(results)} rows") for row in results: try: # Convert protocol_type string to enum protocol_type = ProtocolType(row['protocol_type']) mapping = ProtocolMapping( id=row['mapping_id'], station_id=row['station_id'], pump_id=row['pump_id'], protocol_type=protocol_type, protocol_address=row['protocol_address'], data_type=row['data_type'], db_source=row['db_source'] ) self.protocol_mappings.append(mapping) logger.debug(f"Loaded mapping {row['mapping_id']}: {protocol_type}") except Exception as e: logger.error(f"Failed to create mapping for {row['mapping_id']}: {str(e)}") logger.info(f"Loaded {len(self.protocol_mappings)} protocol mappings from database") except Exception as e: logger.error(f"Failed to load protocol mappings from database: {str(e)}") def configure_protocol(self, config: SCADAProtocolConfig) -> bool: """Configure a SCADA protocol""" try: self.protocol_configs[config.protocol_type] = config logger.info(f"Configured {config.protocol_type.value} protocol: {config.name}") return True except Exception as e: logger.error(f"Failed to configure protocol {config.protocol_type}: {str(e)}") return False def add_pump_station(self, station: PumpStationConfig) -> bool: """Add a pump station configuration""" try: self.stations[station.station_id] = station logger.info(f"Added pump station: {station.name} ({station.station_id})") return True except Exception as e: logger.error(f"Failed to add pump station {station.station_id}: {str(e)}") return False def add_pump(self, pump: PumpConfig) -> bool: """Add a pump configuration""" try: # Verify station exists if pump.station_id not in self.stations: raise ValueError(f"Station {pump.station_id} does not exist") self.pumps[pump.pump_id] = pump logger.info(f"Added pump: {pump.name} ({pump.pump_id}) to station {pump.station_id}") return True except Exception as e: logger.error(f"Failed to add pump {pump.pump_id}: {str(e)}") return False def set_safety_limits(self, limits: SafetyLimitsConfig) -> bool: """Set safety limits for a pump""" try: # Verify pump exists if limits.pump_id not in self.pumps: raise ValueError(f"Pump {limits.pump_id} does not exist") key = f"{limits.station_id}_{limits.pump_id}" self.safety_limits[key] = limits logger.info(f"Set safety limits for pump {limits.pump_id}") return True except Exception as e: logger.error(f"Failed to set safety limits for {limits.pump_id}: {str(e)}") return False def map_data_point(self, mapping: DataPointMapping) -> bool: """Map a data point between protocol and internal representation""" try: # Verify protocol is configured if mapping.protocol_type not in self.protocol_configs: raise ValueError(f"Protocol {mapping.protocol_type} is not configured") # Verify pump exists if mapping.pump_id not in self.pumps: raise ValueError(f"Pump {mapping.pump_id} does not exist") self.data_mappings.append(mapping) logger.info(f"Mapped {mapping.data_type} for pump {mapping.pump_id} to {mapping.protocol_address}") return True except Exception as e: logger.error(f"Failed to map data point for {mapping.pump_id}: {str(e)}") return False def add_protocol_mapping(self, mapping: ProtocolMapping) -> bool: """Add a new protocol mapping with validation""" try: # Validate the mapping validation_result = self.validate_protocol_mapping(mapping) if not validation_result['valid']: raise ValueError(f"Mapping validation failed: {', '.join(validation_result['errors'])}") # # # Verify pump exists # if mapping.pump_id not in self.pumps: # raise ValueError(f"Pump {mapping.pump_id} does not exist") # # # Verify station exists # if mapping.station_id not in self.stations: # raise ValueError(f"Station {mapping.station_id} does not exist") # Save to database if available if self.db_client: query = """ INSERT INTO protocol_mappings (mapping_id, station_id, pump_id, protocol_type, protocol_address, data_type, db_source, created_by, enabled) VALUES (:mapping_id, :station_id, :pump_id, :protocol_type, :protocol_address, :data_type, :db_source, :created_by, :enabled) ON CONFLICT (mapping_id) DO UPDATE SET station_id = EXCLUDED.station_id, pump_id = EXCLUDED.pump_id, protocol_type = EXCLUDED.protocol_type, protocol_address = EXCLUDED.protocol_address, data_type = EXCLUDED.data_type, db_source = EXCLUDED.db_source, enabled = EXCLUDED.enabled, updated_at = CURRENT_TIMESTAMP """ params = { 'mapping_id': mapping.id, 'station_id': mapping.station_id, 'pump_id': mapping.pump_id, 'protocol_type': mapping.protocol_type.value, 'protocol_address': mapping.protocol_address, 'data_type': mapping.data_type, 'db_source': mapping.db_source, 'created_by': 'dashboard', 'enabled': True } self.db_client.execute(query, params) self.protocol_mappings.append(mapping) logger.info(f"Added protocol mapping {mapping.id}: {mapping.protocol_type} for {mapping.station_id}/{mapping.pump_id}") return True except Exception as e: logger.error(f"Failed to add protocol mapping {mapping.id}: {str(e)}") return False def get_protocol_mappings(self, protocol_type: Optional[ProtocolType] = None, station_id: Optional[str] = None, pump_id: Optional[str] = None) -> List[ProtocolMapping]: """Get mappings filtered by protocol/station/pump""" filtered_mappings = self.protocol_mappings.copy() if protocol_type: filtered_mappings = [m for m in filtered_mappings if m.protocol_type == protocol_type] if station_id: filtered_mappings = [m for m in filtered_mappings if m.station_id == station_id] if pump_id: filtered_mappings = [m for m in filtered_mappings if m.pump_id == pump_id] return filtered_mappings def update_protocol_mapping(self, mapping_id: str, updated_mapping: ProtocolMapping) -> bool: """Update an existing protocol mapping""" try: # Find the mapping to update for i, mapping in enumerate(self.protocol_mappings): if mapping.id == mapping_id: # Validate the updated mapping (exclude current mapping from conflict check) validation_result = self.validate_protocol_mapping(updated_mapping, exclude_mapping_id=mapping_id) if not validation_result['valid']: raise ValueError(f"Mapping validation failed: {', '.join(validation_result['errors'])}") # Update in database if available if self.db_client: query = """ UPDATE protocol_mappings SET station_id = :station_id, pump_id = :pump_id, protocol_type = :protocol_type, protocol_address = :protocol_address, data_type = :data_type, db_source = :db_source, updated_at = CURRENT_TIMESTAMP WHERE mapping_id = :mapping_id """ params = { 'mapping_id': mapping_id, 'station_id': updated_mapping.station_id, 'pump_id': updated_mapping.pump_id, 'protocol_type': updated_mapping.protocol_type.value, 'protocol_address': updated_mapping.protocol_address, 'data_type': updated_mapping.data_type, 'db_source': updated_mapping.db_source } self.db_client.execute(query, params) self.protocol_mappings[i] = updated_mapping logger.info(f"Updated protocol mapping {mapping_id}") return True raise ValueError(f"Protocol mapping {mapping_id} not found") except Exception as e: logger.error(f"Failed to update protocol mapping {mapping_id}: {str(e)}") return False def delete_protocol_mapping(self, mapping_id: str) -> bool: """Delete a protocol mapping""" try: initial_count = len(self.protocol_mappings) self.protocol_mappings = [m for m in self.protocol_mappings if m.id != mapping_id] if len(self.protocol_mappings) < initial_count: # Delete from database if available if self.db_client: query = "DELETE FROM protocol_mappings WHERE mapping_id = :mapping_id" self.db_client.execute(query, {'mapping_id': mapping_id}) logger.info(f"Deleted protocol mapping {mapping_id}") return True else: raise ValueError(f"Protocol mapping {mapping_id} not found") except Exception as e: logger.error(f"Failed to delete protocol mapping {mapping_id}: {str(e)}") return False def validate_protocol_mapping(self, mapping: ProtocolMapping, exclude_mapping_id: Optional[str] = None) -> Dict[str, Any]: """Validate protocol mapping for conflicts and protocol-specific rules""" errors = [] warnings = [] # Check for ID conflicts (exclude current mapping when updating) for existing in self.protocol_mappings: if existing.id == mapping.id and existing.id != exclude_mapping_id: errors.append(f"Mapping ID '{mapping.id}' already exists") break # Protocol-specific validation if mapping.protocol_type == ProtocolType.MODBUS_TCP: # Modbus validation try: address = int(mapping.protocol_address) if not (0 <= address <= 65535): errors.append("Modbus address must be between 0 and 65535") # Check for address conflicts within same protocol for existing in self.protocol_mappings: if (existing.id != mapping.id and existing.protocol_type == ProtocolType.MODBUS_TCP and existing.protocol_address == mapping.protocol_address): errors.append(f"Modbus address {mapping.protocol_address} already used by {existing.station_id}/{existing.pump_id}") break except ValueError: errors.append("Modbus address must be a valid integer") elif mapping.protocol_type == ProtocolType.OPC_UA: # OPC UA validation if not mapping.protocol_address.startswith('ns='): errors.append("OPC UA Node ID must start with 'ns='") # Check for node conflicts within same protocol for existing in self.protocol_mappings: if (existing.id != mapping.id and existing.protocol_type == ProtocolType.OPC_UA and existing.protocol_address == mapping.protocol_address): errors.append(f"OPC UA node {mapping.protocol_address} already used by {existing.station_id}/{existing.pump_id}") break elif mapping.protocol_type == ProtocolType.MODBUS_RTU: # Modbus RTU validation (same as Modbus TCP) try: address = int(mapping.protocol_address) if not (0 <= address <= 65535): errors.append("Modbus RTU address must be between 0 and 65535") # Check for address conflicts within same protocol for existing in self.protocol_mappings: if (existing.id != mapping.id and existing.protocol_type == ProtocolType.MODBUS_RTU and existing.protocol_address == mapping.protocol_address): errors.append(f"Modbus RTU address {mapping.protocol_address} already used by {existing.station_id}/{existing.pump_id}") break except ValueError: errors.append("Modbus RTU address must be a valid integer") elif mapping.protocol_type == ProtocolType.REST_API: # REST API validation if not mapping.protocol_address.startswith(('http://', 'https://')): errors.append("REST API endpoint must start with 'http://' or 'https://'") # Check for endpoint conflicts within same protocol for existing in self.protocol_mappings: if (existing.id != mapping.id and existing.protocol_type == ProtocolType.REST_API and existing.protocol_address == mapping.protocol_address): errors.append(f"REST API endpoint {mapping.protocol_address} already used by {existing.station_id}/{existing.pump_id}") break # Check database source format if '.' not in mapping.db_source: warnings.append("Database source should be in format 'table.column'") return { 'valid': len(errors) == 0, 'errors': errors, 'warnings': warnings } def auto_discover_hardware(self) -> HardwareDiscoveryResult: """Auto-discover connected hardware and SCADA systems""" result = HardwareDiscoveryResult(success=True) try: # This would integrate with actual hardware discovery # For now, provide mock discovery for demonstration # Mock OPC UA discovery if ProtocolType.OPC_UA in self.protocol_configs: logger.info("Performing OPC UA hardware discovery...") # Simulate discovering a station via OPC UA mock_station = PumpStationConfig( station_id="discovered_station_001", name="Discovered Pump Station", location="Building A", max_pumps=2, power_capacity=100.0 ) result.discovered_stations.append(mock_station) # Simulate discovering pumps mock_pump = PumpConfig( pump_id="discovered_pump_001", station_id="discovered_station_001", name="Discovered Primary Pump", type="centrifugal", power_rating=55.0, max_speed=50.0, min_speed=20.0 ) result.discovered_pumps.append(mock_pump) # Mock Modbus discovery if ProtocolType.MODBUS_TCP in self.protocol_configs: logger.info("Performing Modbus TCP hardware discovery...") result.warnings.append("Modbus discovery requires manual configuration") logger.info(f"Hardware discovery completed: {len(result.discovered_stations)} stations, {len(result.discovered_pumps)} pumps found") except Exception as e: result.success = False result.errors.append(f"Hardware discovery failed: {str(e)}") logger.error(f"Hardware discovery failed: {str(e)}") return result def validate_configuration(self) -> Dict[str, Any]: """Validate the complete configuration""" validation_result = { "valid": True, "errors": [], "warnings": [], "summary": {} } # Check protocol configurations if not self.protocol_configs: validation_result["warnings"].append("No SCADA protocols configured") # Check stations and pumps if not self.stations: validation_result["warnings"].append("No pump stations configured") # Check data mappings if not self.data_mappings: validation_result["warnings"].append("No data point mappings configured") # Check protocol mappings if not self.protocol_mappings: validation_result["warnings"].append("No protocol mappings configured") # Check safety limits pumps_without_limits = set(self.pumps.keys()) - set(limit.pump_id for limit in self.safety_limits.values()) if pumps_without_limits: validation_result["warnings"].append(f"Pumps without safety limits: {', '.join(pumps_without_limits)}") # Validate individual protocol mappings for mapping in self.protocol_mappings: mapping_validation = self.validate_protocol_mapping(mapping) if not mapping_validation['valid']: validation_result['errors'].extend([f"Mapping {mapping.id}: {error}" for error in mapping_validation['errors']]) validation_result['warnings'].extend([f"Mapping {mapping.id}: {warning}" for warning in mapping_validation['warnings']]) # Create summary validation_result["summary"] = { "protocols_configured": len(self.protocol_configs), "stations_configured": len(self.stations), "pumps_configured": len(self.pumps), "safety_limits_set": len(self.safety_limits), "data_mappings": len(self.data_mappings), "protocol_mappings": len(self.protocol_mappings) } return validation_result def export_configuration(self) -> Dict[str, Any]: """Export complete configuration for backup""" return { "protocols": {pt.value: config.dict() for pt, config in self.protocol_configs.items()}, "stations": {sid: station.dict() for sid, station in self.stations.items()}, "pumps": {pid: pump.dict() for pid, pump in self.pumps.items()}, "safety_limits": {key: limits.dict() for key, limits in self.safety_limits.items()}, "data_mappings": [mapping.dict() for mapping in self.data_mappings], "protocol_mappings": [mapping.dict() for mapping in self.protocol_mappings] } def import_configuration(self, config_data: Dict[str, Any]) -> bool: """Import configuration from backup""" try: # Clear existing configuration self.protocol_configs.clear() self.stations.clear() self.pumps.clear() self.safety_limits.clear() self.data_mappings.clear() self.protocol_mappings.clear() # Import protocols for pt_str, config_dict in config_data.get("protocols", {}).items(): protocol_type = ProtocolType(pt_str) if protocol_type == ProtocolType.OPC_UA: config = OPCUAConfig(**config_dict) elif protocol_type == ProtocolType.MODBUS_TCP: config = ModbusTCPConfig(**config_dict) else: config = SCADAProtocolConfig(**config_dict) self.protocol_configs[protocol_type] = config # Import stations for sid, station_dict in config_data.get("stations", {}).items(): station = PumpStationConfig(**station_dict) self.stations[sid] = station # Import pumps for pid, pump_dict in config_data.get("pumps", {}).items(): pump = PumpConfig(**pump_dict) self.pumps[pid] = pump # Import safety limits for key, limits_dict in config_data.get("safety_limits", {}).items(): limits = SafetyLimitsConfig(**limits_dict) self.safety_limits[key] = limits # Import data mappings for mapping_dict in config_data.get("data_mappings", []): mapping = DataPointMapping(**mapping_dict) self.data_mappings.append(mapping) # Import protocol mappings for mapping_dict in config_data.get("protocol_mappings", []): mapping = ProtocolMapping(**mapping_dict) self.protocol_mappings.append(mapping) logger.info("Configuration imported successfully") return True except Exception as e: logger.error(f"Failed to import configuration: {str(e)}") return False # Global configuration manager instance configuration_manager = ConfigurationManager()