2025-11-01 10:28:25 +00:00
|
|
|
"""
|
|
|
|
|
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] = {}
|
|
|
|
|
|
2025-11-04 09:14:11 +00:00
|
|
|
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
|
|
|
|
|
|
2025-11-01 10:28:25 +00:00
|
|
|
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"""
|
|
|
|
|
|
2025-11-04 09:14:11 +00:00
|
|
|
def __init__(self, db_client=None):
|
2025-11-01 10:28:25 +00:00
|
|
|
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] = []
|
2025-11-04 09:14:11 +00:00
|
|
|
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)}")
|
2025-11-01 10:28:25 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2025-11-04 09:14:11 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-01 10:28:25 +00:00
|
|
|
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")
|
|
|
|
|
|
2025-11-04 09:14:11 +00:00
|
|
|
# Check protocol mappings
|
|
|
|
|
if not self.protocol_mappings:
|
|
|
|
|
validation_result["warnings"].append("No protocol mappings configured")
|
|
|
|
|
|
2025-11-01 10:28:25 +00:00
|
|
|
# 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)}")
|
|
|
|
|
|
2025-11-04 09:14:11 +00:00
|
|
|
# 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']])
|
|
|
|
|
|
2025-11-01 10:28:25 +00:00
|
|
|
# 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),
|
2025-11-04 09:14:11 +00:00
|
|
|
"data_mappings": len(self.data_mappings),
|
|
|
|
|
"protocol_mappings": len(self.protocol_mappings)
|
2025-11-01 10:28:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()},
|
2025-11-04 09:14:11 +00:00
|
|
|
"data_mappings": [mapping.dict() for mapping in self.data_mappings],
|
|
|
|
|
"protocol_mappings": [mapping.dict() for mapping in self.protocol_mappings]
|
2025-11-01 10:28:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
2025-11-04 09:14:11 +00:00
|
|
|
self.protocol_mappings.clear()
|
2025-11-01 10:28:25 +00:00
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
2025-11-04 09:14:11 +00:00
|
|
|
# Import protocol mappings
|
|
|
|
|
for mapping_dict in config_data.get("protocol_mappings", []):
|
|
|
|
|
mapping = ProtocolMapping(**mapping_dict)
|
|
|
|
|
self.protocol_mappings.append(mapping)
|
|
|
|
|
|
2025-11-01 10:28:25 +00:00
|
|
|
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()
|