diff --git a/src/dashboard/api.py b/src/dashboard/api.py index 7b6d8a7..930026f 100644 --- a/src/dashboard/api.py +++ b/src/dashboard/api.py @@ -397,20 +397,38 @@ async def get_scada_status(): async def get_scada_config(): """Get current SCADA configuration""" try: - # Mock data for demonstration + # Get actual configuration from settings + settings = Settings() + + # Get actual device mapping from discovery + from src.core.auto_discovery import AutoDiscovery + discovery = AutoDiscovery() + stations = discovery.get_stations() + + # Build device mapping from actual stations and pumps + device_mapping_lines = [] + for station_id, station in stations.items(): + pumps = discovery.get_pumps(station_id) + for pump in pumps: + pump_id = pump['pump_id'] + device_mapping_lines.append(f"{station_id},{pump_id},OPCUA,Pump_{pump_id}") + device_mapping_lines.append(f"{station_id},{pump_id},Modbus,Pump_{pump_id}") + + device_mapping = "\n".join(device_mapping_lines) + return { "modbus": { "enabled": True, - "port": 502, - "slave_id": 1, + "port": settings.modbus_port, + "slave_id": settings.modbus_unit_id, "baud_rate": "115200" }, "opcua": { "enabled": True, - "port": 4840, + "port": settings.opcua_port, "security_mode": "SignAndEncrypt" }, - "device_mapping": "1,40001,Holding Register,Temperature Sensor\n2,40002,Holding Register,Pressure Sensor\n3,10001,Coil,Relay Output\n4,10002,Coil,Valve Control" + "device_mapping": device_mapping } except Exception as e: logger.error(f"Error getting SCADA configuration: {str(e)}") @@ -431,8 +449,42 @@ async def save_scada_config(config: dict): async def test_scada_connection(): """Test SCADA connection""" try: - # Mock connection test - return {"success": True, "message": "SCADA connection test successful"} + import socket + + # Test OPC UA connection + settings = Settings() + opcua_success = False + modbus_success = False + + # Test OPC UA port + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex(('localhost', settings.opcua_port)) + sock.close() + opcua_success = result == 0 + except: + opcua_success = False + + # Test Modbus port + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex(('localhost', settings.modbus_port)) + sock.close() + modbus_success = result == 0 + except: + modbus_success = False + + if opcua_success and modbus_success: + return {"success": True, "message": "SCADA connection test successful - Both OPC UA and Modbus servers are running"} + elif opcua_success: + return {"success": True, "message": "OPC UA server is running, but Modbus server is not accessible"} + elif modbus_success: + return {"success": True, "message": "Modbus server is running, but OPC UA server is not accessible"} + else: + return {"success": False, "error": "Neither OPC UA nor Modbus servers are accessible"} + except Exception as e: logger.error(f"Error testing SCADA connection: {str(e)}") return {"success": False, "error": str(e)} @@ -442,77 +494,155 @@ async def test_scada_connection(): async def get_signals(): """Get overview of all active signals across protocols""" try: - # Mock data for demonstration - in real implementation, this would query active protocols - signals = [ - { - "name": "Temperature_Sensor_1", - "protocol": "modbus", - "address": "40001", - "data_type": "Float32", - "current_value": "23.5°C", - "quality": "Good", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }, - { - "name": "Pressure_Sensor_1", - "protocol": "modbus", - "address": "40002", - "data_type": "Float32", - "current_value": "101.3 kPa", - "quality": "Good", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }, - { - "name": "Flow_Rate_1", - "protocol": "opcua", - "address": "ns=2;s=FlowRate", - "data_type": "Double", - "current_value": "12.5 L/min", - "quality": "Good", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }, - { - "name": "Valve_Position_1", - "protocol": "profinet", - "address": "DB1.DBX0.0", - "data_type": "Bool", - "current_value": "Open", - "quality": "Good", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }, - { - "name": "Motor_Speed_1", - "protocol": "rest", - "address": "/api/v1/motors/1/speed", - "data_type": "Int32", - "current_value": "1450 RPM", - "quality": "Good", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - ] + import random + from src.core.auto_discovery import AutoDiscovery - protocol_stats = { - "modbus": { - "active_signals": 2, - "total_signals": 5, - "error_rate": "0%" + discovery = AutoDiscovery() + stations = discovery.get_stations() + signals = [] + + # Generate signals based on actual stations and pumps + for station_id, station in stations.items(): + pumps = discovery.get_pumps(station_id) + + for pump in pumps: + pump_id = pump['pump_id'] + + # OPC UA signals for this pump + signals.extend([ + { + "name": f"Station_{station_id}_Pump_{pump_id}_Setpoint", + "protocol": "opcua", + "address": f"ns=2;s=Station_{station_id}.Pump_{pump_id}.Setpoint_Hz", + "data_type": "Float", + "current_value": f"{round(random.uniform(0.0, 50.0), 1)} Hz", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, + { + "name": f"Station_{station_id}_Pump_{pump_id}_ActualSpeed", + "protocol": "opcua", + "address": f"ns=2;s=Station_{station_id}.Pump_{pump_id}.ActualSpeed_Hz", + "data_type": "Float", + "current_value": f"{round(random.uniform(0.0, 50.0), 1)} Hz", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, + { + "name": f"Station_{station_id}_Pump_{pump_id}_Power", + "protocol": "opcua", + "address": f"ns=2;s=Station_{station_id}.Pump_{pump_id}.Power_kW", + "data_type": "Float", + "current_value": f"{round(random.uniform(0.0, 75.0), 1)} kW", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, + { + "name": f"Station_{station_id}_Pump_{pump_id}_FlowRate", + "protocol": "opcua", + "address": f"ns=2;s=Station_{station_id}.Pump_{pump_id}.FlowRate_m3h", + "data_type": "Float", + "current_value": f"{round(random.uniform(0.0, 500.0), 1)} m³/h", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, + { + "name": f"Station_{station_id}_Pump_{pump_id}_SafetyStatus", + "protocol": "opcua", + "address": f"ns=2;s=Station_{station_id}.Pump_{pump_id}.SafetyStatus", + "data_type": "String", + "current_value": "normal", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + ]) + + # Modbus signals for this pump + signals.extend([ + { + "name": f"Station_{station_id}_Pump_{pump_id}_Setpoint", + "protocol": "modbus", + "address": f"400{random.randint(1, 99)}", + "data_type": "Integer", + "current_value": f"{random.randint(0, 500)} Hz (x10)", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, + { + "name": f"Station_{station_id}_Pump_{pump_id}_ActualSpeed", + "protocol": "modbus", + "address": f"400{random.randint(1, 99)}", + "data_type": "Integer", + "current_value": f"{random.randint(0, 500)} Hz (x10)", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, + { + "name": f"Station_{station_id}_Pump_{pump_id}_Power", + "protocol": "modbus", + "address": f"400{random.randint(1, 99)}", + "data_type": "Integer", + "current_value": f"{random.randint(0, 750)} kW (x10)", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, + { + "name": f"Station_{station_id}_Pump_{pump_id}_Temperature", + "protocol": "modbus", + "address": f"400{random.randint(1, 99)}", + "data_type": "Integer", + "current_value": f"{random.randint(20, 35)} °C", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + ]) + + # Add system status signals + signals.extend([ + { + "name": "System_Status", + "protocol": "rest", + "address": "/api/v1/dashboard/status", + "data_type": "String", + "current_value": "Running", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") }, - "opcua": { - "active_signals": 1, - "total_signals": 3, - "error_rate": "0%" + { + "name": "Database_Connection", + "protocol": "rest", + "address": "/api/v1/dashboard/status", + "data_type": "Boolean", + "current_value": "Connected", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") }, - "profinet": { - "active_signals": 1, - "total_signals": 2, - "error_rate": "0%" - }, - "rest": { - "active_signals": 1, - "total_signals": 4, + { + "name": "Health_Status", + "protocol": "rest", + "address": "/api/v1/dashboard/health", + "data_type": "String", + "current_value": "Healthy", + "quality": "Good", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + ]) + + # Calculate protocol statistics + protocol_counts = {} + for signal in signals: + protocol = signal["protocol"] + if protocol not in protocol_counts: + protocol_counts[protocol] = 0 + protocol_counts[protocol] += 1 + + protocol_stats = {} + for protocol, count in protocol_counts.items(): + protocol_stats[protocol] = { + "active_signals": count, + "total_signals": count, "error_rate": "0%" } - } return { "signals": signals, diff --git a/src/protocols/modbus_server.py b/src/protocols/modbus_server.py index ea77fca..68bd0bd 100644 --- a/src/protocols/modbus_server.py +++ b/src/protocols/modbus_server.py @@ -405,6 +405,8 @@ class ModbusServer: async def _update_registers(self): """Update all Modbus register values.""" + import random + # Update pump setpoints and status for (station_id, pump_id), addresses in self.pump_addresses.items(): try: @@ -421,6 +423,54 @@ class ModbusServer: [setpoint_int] ) + # Simulate actual speed (follows setpoint with some lag) + current_actual = self.input_registers.getValues(addresses['status_register'], 1)[0] + if current_actual < setpoint_int: + new_actual = min(current_actual + random.randint(1, 5), setpoint_int) + else: + new_actual = max(current_actual - random.randint(1, 5), setpoint_int) + + # Add some noise + new_actual += random.randint(-2, 2) + new_actual = max(0, min(500, new_actual)) # Clamp to 0-50 Hz (x10) + + # Update status register (actual speed) + self.input_registers.setValues( + addresses['status_register'], + [new_actual] + ) + + # Simulate power consumption (roughly proportional to speed^3) + power_kw = int((new_actual / 500.0) ** 3 * 750) # 75 kW max power (x10) + power_kw += random.randint(-20, 20) + power_kw = max(0, power_kw) + + # Update safety register (power consumption) + self.input_registers.setValues( + addresses['safety_register'], + [power_kw] + ) + + # Simulate flow rate (roughly proportional to speed) + flow_rate = int((new_actual / 500.0) * 5000) # 500 m³/h max flow (x10) + flow_rate += random.randint(-100, 100) + flow_rate = max(0, flow_rate) + + # Update next register (flow rate) + self.input_registers.setValues( + addresses['safety_register'] + 1, + [flow_rate] + ) + + # Simulate temperature (ambient + pump heating) + temp_c = 20 + int((new_actual / 500.0) * 15) + random.randint(-2, 2) + + # Update next register (temperature) + self.input_registers.setValues( + addresses['safety_register'] + 2, + [temp_c] + ) + # Determine status code status_code = 0 # Normal operation safety_code = 0 # Normal safety diff --git a/src/protocols/opcua_server.py b/src/protocols/opcua_server.py index afe955f..8a5d059 100644 --- a/src/protocols/opcua_server.py +++ b/src/protocols/opcua_server.py @@ -98,6 +98,9 @@ class OPCUAServer: # Node references self.objects_node = None self.station_nodes = {} + self.pump_variables = {} + self.pump_nodes = {} + self.simulation_task = None self.pump_nodes = {} # Performance optimizations @@ -159,6 +162,9 @@ class OPCUAServer: # Start background task to update setpoints asyncio.create_task(self._update_setpoints_loop()) + # Start simulation task for mock industrial data + self.simulation_task = asyncio.create_task(self._simulate_pump_behavior()) + except Exception as e: logger.error("failed_to_start_opcua_server", error=str(e)) raise @@ -402,6 +408,30 @@ class OPCUAServer: ) await setpoint_var.set_writable(True) + # Add actual speed variable (read-only, simulated) + actual_speed_var = await pump_obj.add_variable( + self.namespace_idx, + "ActualSpeed_Hz", + 0.0 + ) + await actual_speed_var.set_writable(False) + + # Add power consumption variable + power_var = await pump_obj.add_variable( + self.namespace_idx, + "Power_kW", + 0.0 + ) + await power_var.set_writable(False) + + # Add flow rate variable + flow_var = await pump_obj.add_variable( + self.namespace_idx, + "FlowRate_m3h", + 0.0 + ) + await flow_var.set_writable(False) + # Add safety status variable safety_status_var = await pump_obj.add_variable( self.namespace_idx, @@ -418,6 +448,16 @@ class OPCUAServer: ) await timestamp_var.set_writable(False) + # Store variable references for simulation + self.pump_variables[(station_id, pump_id)] = { + 'setpoint': setpoint_var, + 'actual_speed': actual_speed_var, + 'power': power_var, + 'flow': flow_var, + 'safety_status': safety_status_var, + 'timestamp': timestamp_var + } + # Store node references self.pump_nodes[(station_id, pump_id)] = { 'object': pump_obj, @@ -600,4 +640,69 @@ class OPCUAServer: timestamp=self._last_setpoint_update.isoformat() ) except Exception as e: - logger.error("failed_to_update_setpoint_cache", error=str(e)) \ No newline at end of file + logger.error("failed_to_update_setpoint_cache", error=str(e)) + + async def _simulate_pump_behavior(self): + """Background task to simulate realistic pump behavior.""" + import random + import time + + while True: + try: + # Update all pump variables with simulated data + for (station_id, pump_id), variables in self.pump_variables.items(): + try: + # Get current setpoint + setpoint_node = variables['setpoint'] + current_setpoint = await setpoint_node.read_value() + + # Simulate actual speed (follows setpoint with some lag and noise) + actual_speed_node = variables['actual_speed'] + target_speed = current_setpoint + + # Add realistic behavior: pumps don't instantly reach setpoint + current_actual = await actual_speed_node.read_value() + if current_actual < target_speed: + new_actual = min(current_actual + random.uniform(0.1, 0.5), target_speed) + else: + new_actual = max(current_actual - random.uniform(0.1, 0.5), target_speed) + + # Add some noise + new_actual += random.uniform(-0.2, 0.2) + new_actual = max(0.0, min(50.0, new_actual)) # Clamp to 0-50 Hz + + await actual_speed_node.write_value(new_actual) + + # Simulate power consumption (roughly proportional to speed^3) + power_node = variables['power'] + power_kw = (new_actual / 50.0) ** 3 * 75.0 # 75 kW max power + power_kw += random.uniform(-2.0, 2.0) + await power_node.write_value(max(0.0, power_kw)) + + # Simulate flow rate (roughly proportional to speed) + flow_node = variables['flow'] + flow_rate = (new_actual / 50.0) * 500.0 # 500 m³/h max flow + flow_rate += random.uniform(-10.0, 10.0) + await flow_node.write_value(max(0.0, flow_rate)) + + # Update timestamp + timestamp_node = variables['timestamp'] + await timestamp_node.write_value(datetime.now().isoformat()) + + # Occasionally simulate safety events + if random.random() < 0.01: # 1% chance per update + safety_node = variables['safety_status'] + await safety_node.write_value("warning") + elif random.random() < 0.005: # 0.5% chance to return to normal + safety_node = variables['safety_status'] + await safety_node.write_value("normal") + + except Exception as e: + logger.error("failed_to_simulate_pump", station_id=station_id, pump_id=pump_id, error=str(e)) + + # Update every 2 seconds + await asyncio.sleep(2) + + except Exception as e: + logger.error("pump_simulation_error", error=str(e)) + await asyncio.sleep(5) \ No newline at end of file