diff --git a/LEGACY_SYSTEM_REMOVAL_SUMMARY.md b/LEGACY_SYSTEM_REMOVAL_SUMMARY.md new file mode 100644 index 0000000..9870478 --- /dev/null +++ b/LEGACY_SYSTEM_REMOVAL_SUMMARY.md @@ -0,0 +1,70 @@ +# Legacy System Removal Summary + +## Overview +Successfully removed the legacy station/pump configuration system and fully integrated the tag-based metadata system throughout the Calejo Control application. + +## Changes Made + +### 1. Configuration Manager (`src/dashboard/configuration_manager.py`) +- **Removed legacy classes**: `PumpStationConfig`, `PumpConfig`, `SafetyLimitsConfig` +- **Updated `ProtocolMapping` model**: Added validators to check `station_id`, `equipment_id`, and `data_type_id` against the tag metadata system +- **Updated `HardwareDiscoveryResult`**: Changed from legacy class references to generic dictionaries +- **Cleaned up configuration methods**: Removed legacy configuration export/import methods + +### 2. API Endpoints (`src/dashboard/api.py`) +- **Removed legacy endpoints**: `/configure/station`, `/configure/pump`, `/configure/safety-limits` +- **Added tag metadata endpoints**: `/metadata/stations`, `/metadata/equipment`, `/metadata/data-types` +- **Updated protocol mapping endpoints**: Now validate against tag metadata system + +### 3. UI Templates (`src/dashboard/templates.py`) +- **Replaced text inputs with dropdowns**: For `station_id`, `equipment_id`, and `data_type_id` fields +- **Added dynamic loading**: Dropdowns are populated from tag metadata API endpoints +- **Updated form validation**: Now validates against available tag metadata + +### 4. JavaScript (`static/protocol_mapping.js`) +- **Added tag metadata loading functions**: `loadTagMetadata()`, `populateStationDropdown()`, `populateEquipmentDropdown()`, `populateDataTypeDropdown()` +- **Updated form handling**: Now validates against tag metadata before submission +- **Enhanced user experience**: Dropdowns provide selection from available tag metadata + +### 5. Security Module (`src/core/security.py`) +- **Removed legacy permissions**: `configure_safety_limits` permission removed from ENGINEER and ADMINISTRATOR roles + +## Technical Details + +### Validation System +- **Station Validation**: `station_id` must exist in tag metadata stations +- **Equipment Validation**: `equipment_id` must exist in tag metadata equipment +- **Data Type Validation**: `data_type_id` must exist in tag metadata data types + +### API Integration +- **Metadata Endpoints**: Provide real-time access to tag metadata +- **Protocol Mapping**: All mappings now reference tag metadata IDs +- **Error Handling**: Clear validation errors when tag metadata doesn't exist + +### User Interface +- **Dropdown Selection**: Users select from available tag metadata instead of manual entry +- **Dynamic Loading**: Dropdowns populated from API endpoints on page load +- **Validation Feedback**: Clear error messages when invalid selections are made + +## Benefits + +1. **Single Source of Truth**: All stations, equipment, and data types are defined in the tag metadata system +2. **Data Consistency**: Eliminates manual entry errors and ensures valid references +3. **Improved User Experience**: Dropdown selection is faster and more reliable than manual entry +4. **System Integrity**: Validators prevent invalid configurations from being saved +5. **Maintainability**: Simplified codebase with unified metadata approach + +## Testing + +All integration tests passed: +- ✅ Configuration manager imports without legacy classes +- ✅ ProtocolMapping validators check against tag metadata system +- ✅ API endpoints use tag metadata system +- ✅ UI templates use dropdowns instead of text inputs +- ✅ Legacy endpoints and classes completely removed + +## Migration Notes + +- Existing protocol mappings will need to be updated to use valid tag metadata IDs +- Tag metadata must be populated before creating new protocol mappings +- The system now requires all stations, equipment, and data types to be defined in the tag metadata system before use \ No newline at end of file diff --git a/src/core/metadata_manager.py b/src/core/metadata_manager.py new file mode 100644 index 0000000..f9b0ac0 --- /dev/null +++ b/src/core/metadata_manager.py @@ -0,0 +1,324 @@ +""" +Metadata Manager for Calejo Control Adapter + +Provides industry-agnostic metadata management for: +- Stations/Assets +- Equipment/Devices +- Data types and signal mappings +- Signal preprocessing rules +""" + +from typing import Dict, List, Optional, Any, Union +from enum import Enum +from pydantic import BaseModel, validator +import structlog + +logger = structlog.get_logger() + + +class IndustryType(str, Enum): + """Supported industry types""" + WASTEWATER = "wastewater" + WATER_TREATMENT = "water_treatment" + MANUFACTURING = "manufacturing" + ENERGY = "energy" + HVAC = "hvac" + CUSTOM = "custom" + + +class DataCategory(str, Enum): + """Data categories for different signal types""" + CONTROL = "control" # Setpoints, commands + MONITORING = "monitoring" # Status, measurements + SAFETY = "safety" # Safety limits, emergency stops + DIAGNOSTIC = "diagnostic" # Diagnostics, health + OPTIMIZATION = "optimization" # Optimization outputs + + +class SignalTransformation(BaseModel): + """Signal transformation rule for preprocessing""" + name: str + transformation_type: str # scale, offset, clamp, linear_map, custom + parameters: Dict[str, Any] + description: str = "" + + @validator('transformation_type') + def validate_transformation_type(cls, v): + valid_types = ['scale', 'offset', 'clamp', 'linear_map', 'custom'] + if v not in valid_types: + raise ValueError(f"Transformation type must be one of: {valid_types}") + return v + + +class DataTypeMapping(BaseModel): + """Data type mapping configuration""" + data_type: str + category: DataCategory + unit: str + min_value: Optional[float] = None + max_value: Optional[float] = None + default_value: Optional[float] = None + transformation_rules: List[SignalTransformation] = [] + description: str = "" + + +class AssetMetadata(BaseModel): + """Base asset metadata (station/equipment)""" + asset_id: str + name: str + industry_type: IndustryType + location: Optional[str] = None + coordinates: Optional[Dict[str, float]] = None + metadata: Dict[str, Any] = {} + + @validator('asset_id') + def validate_asset_id(cls, v): + if not v.replace('_', '').isalnum(): + raise ValueError("Asset ID must be alphanumeric with underscores") + return v + + +class StationMetadata(AssetMetadata): + """Station/Plant metadata""" + station_type: str = "general" + capacity: Optional[float] = None + equipment_count: int = 0 + + +class EquipmentMetadata(AssetMetadata): + """Equipment/Device metadata""" + station_id: str + equipment_type: str + manufacturer: Optional[str] = None + model: Optional[str] = None + control_type: Optional[str] = None + rated_power: Optional[float] = None + min_operating_value: Optional[float] = None + max_operating_value: Optional[float] = None + default_setpoint: Optional[float] = None + + +class MetadataManager: + """Manages metadata across different industries and data sources""" + + def __init__(self, db_client=None): + self.db_client = db_client + self.stations: Dict[str, StationMetadata] = {} + self.equipment: Dict[str, EquipmentMetadata] = {} + self.data_types: Dict[str, DataTypeMapping] = {} + self.industry_configs: Dict[IndustryType, Dict[str, Any]] = {} + + # Initialize with default data types + self._initialize_default_data_types() + + def _initialize_default_data_types(self): + """Initialize default data types for common industries""" + + # Control data types + self.data_types["setpoint"] = DataTypeMapping( + data_type="setpoint", + category=DataCategory.CONTROL, + unit="Hz", + min_value=20.0, + max_value=50.0, + default_value=35.0, + description="Frequency setpoint for VFD control" + ) + + self.data_types["pressure_setpoint"] = DataTypeMapping( + data_type="pressure_setpoint", + category=DataCategory.CONTROL, + unit="bar", + min_value=0.0, + max_value=10.0, + description="Pressure setpoint for pump control" + ) + + # Monitoring data types + self.data_types["actual_speed"] = DataTypeMapping( + data_type="actual_speed", + category=DataCategory.MONITORING, + unit="Hz", + description="Actual motor speed" + ) + + self.data_types["power"] = DataTypeMapping( + data_type="power", + category=DataCategory.MONITORING, + unit="kW", + description="Power consumption" + ) + + self.data_types["flow"] = DataTypeMapping( + data_type="flow", + category=DataCategory.MONITORING, + unit="m³/h", + description="Flow rate" + ) + + self.data_types["level"] = DataTypeMapping( + data_type="level", + category=DataCategory.MONITORING, + unit="m", + description="Liquid level" + ) + + # Safety data types + self.data_types["emergency_stop"] = DataTypeMapping( + data_type="emergency_stop", + category=DataCategory.SAFETY, + unit="boolean", + description="Emergency stop status" + ) + + # Optimization data types + self.data_types["optimized_setpoint"] = DataTypeMapping( + data_type="optimized_setpoint", + category=DataCategory.OPTIMIZATION, + unit="Hz", + min_value=20.0, + max_value=50.0, + description="Optimized frequency setpoint from AI/ML" + ) + + def add_station(self, station: StationMetadata) -> bool: + """Add a station to metadata manager""" + try: + self.stations[station.asset_id] = station + logger.info("station_added", station_id=station.asset_id, industry=station.industry_type) + return True + except Exception as e: + logger.error("failed_to_add_station", station_id=station.asset_id, error=str(e)) + return False + + def add_equipment(self, equipment: EquipmentMetadata) -> bool: + """Add equipment to metadata manager""" + try: + # Verify station exists + if equipment.station_id not in self.stations: + logger.warning("unknown_station_for_equipment", + equipment_id=equipment.asset_id, station_id=equipment.station_id) + + self.equipment[equipment.asset_id] = equipment + + # Update station equipment count + if equipment.station_id in self.stations: + self.stations[equipment.station_id].equipment_count += 1 + + logger.info("equipment_added", + equipment_id=equipment.asset_id, + station_id=equipment.station_id, + equipment_type=equipment.equipment_type) + return True + except Exception as e: + logger.error("failed_to_add_equipment", equipment_id=equipment.asset_id, error=str(e)) + return False + + def add_data_type(self, data_type: DataTypeMapping) -> bool: + """Add a custom data type""" + try: + self.data_types[data_type.data_type] = data_type + logger.info("data_type_added", data_type=data_type.data_type, category=data_type.category) + return True + except Exception as e: + logger.error("failed_to_add_data_type", data_type=data_type.data_type, error=str(e)) + return False + + def get_stations(self, industry_type: Optional[IndustryType] = None) -> List[StationMetadata]: + """Get all stations, optionally filtered by industry""" + if industry_type: + return [station for station in self.stations.values() + if station.industry_type == industry_type] + return list(self.stations.values()) + + def get_equipment(self, station_id: Optional[str] = None) -> List[EquipmentMetadata]: + """Get all equipment, optionally filtered by station""" + if station_id: + return [equip for equip in self.equipment.values() + if equip.station_id == station_id] + return list(self.equipment.values()) + + def get_data_types(self, category: Optional[DataCategory] = None) -> List[DataTypeMapping]: + """Get all data types, optionally filtered by category""" + if category: + return [dt for dt in self.data_types.values() if dt.category == category] + return list(self.data_types.values()) + + def get_available_data_types_for_equipment(self, equipment_id: str) -> List[DataTypeMapping]: + """Get data types suitable for specific equipment""" + equipment = self.equipment.get(equipment_id) + if not equipment: + return [] + + # Filter data types based on equipment type and industry + suitable_types = [] + for data_type in self.data_types.values(): + # Basic filtering logic - can be extended based on equipment metadata + if data_type.category in [DataCategory.CONTROL, DataCategory.MONITORING, DataCategory.OPTIMIZATION]: + suitable_types.append(data_type) + + return suitable_types + + def apply_transformation(self, value: float, data_type: str) -> float: + """Apply transformation rules to a value""" + if data_type not in self.data_types: + return value + + data_type_config = self.data_types[data_type] + transformed_value = value + + for transformation in data_type_config.transformation_rules: + transformed_value = self._apply_single_transformation(transformed_value, transformation) + + return transformed_value + + def _apply_single_transformation(self, value: float, transformation: SignalTransformation) -> float: + """Apply a single transformation rule""" + params = transformation.parameters + + if transformation.transformation_type == "scale": + return value * params.get("factor", 1.0) + + elif transformation.transformation_type == "offset": + return value + params.get("offset", 0.0) + + elif transformation.transformation_type == "clamp": + min_val = params.get("min", float('-inf')) + max_val = params.get("max", float('inf')) + return max(min_val, min(value, max_val)) + + elif transformation.transformation_type == "linear_map": + # Map from [input_min, input_max] to [output_min, output_max] + input_min = params.get("input_min", 0.0) + input_max = params.get("input_max", 1.0) + output_min = params.get("output_min", 0.0) + output_max = params.get("output_max", 1.0) + + if input_max == input_min: + return output_min + + normalized = (value - input_min) / (input_max - input_min) + return output_min + normalized * (output_max - output_min) + + # For custom transformations, would need to implement specific logic + return value + + def get_metadata_summary(self) -> Dict[str, Any]: + """Get summary of all metadata""" + return { + "station_count": len(self.stations), + "equipment_count": len(self.equipment), + "data_type_count": len(self.data_types), + "stations_by_industry": { + industry.value: len([s for s in self.stations.values() if s.industry_type == industry]) + for industry in IndustryType + }, + "data_types_by_category": { + category.value: len([dt for dt in self.data_types.values() if dt.category == category]) + for category in DataCategory + } + } + + +# Global metadata manager instance +metadata_manager = MetadataManager() \ No newline at end of file diff --git a/src/core/security.py b/src/core/security.py index 9406cf0..433d9a7 100644 --- a/src/core/security.py +++ b/src/core/security.py @@ -236,7 +236,6 @@ class AuthorizationManager: "emergency_stop", "clear_emergency_stop", "view_alerts", - "configure_safety_limits", "manage_pump_configuration", "view_system_metrics" }, @@ -247,7 +246,6 @@ class AuthorizationManager: "emergency_stop", "clear_emergency_stop", "view_alerts", - "configure_safety_limits", "manage_pump_configuration", "view_system_metrics", "manage_users", diff --git a/src/core/tag_metadata_manager.py b/src/core/tag_metadata_manager.py new file mode 100644 index 0000000..5f547e3 --- /dev/null +++ b/src/core/tag_metadata_manager.py @@ -0,0 +1,308 @@ +""" +Tag-Based Metadata Manager + +A flexible, tag-based metadata system that replaces the industry-specific approach. +Users can define their own tags and attributes for stations, equipment, and data types. +""" + +import json +import logging +from typing import Dict, List, Optional, Any, Set +from enum import Enum +from dataclasses import dataclass, asdict +import uuid + +logger = logging.getLogger(__name__) + + +class TagCategory(Enum): + """Core tag categories for consistency""" + FUNCTION = "function" + SIGNAL_TYPE = "signal_type" + EQUIPMENT_TYPE = "equipment_type" + LOCATION = "location" + STATUS = "status" + + +@dataclass +class Tag: + """Individual tag with optional description""" + name: str + category: Optional[str] = None + description: Optional[str] = None + + +@dataclass +class MetadataEntity: + """Base class for all metadata entities""" + id: str + name: str + tags: List[str] + attributes: Dict[str, Any] + description: Optional[str] = None + + +@dataclass +class Station(MetadataEntity): + """Station metadata""" + pass + + +@dataclass +class Equipment(MetadataEntity): + """Equipment metadata""" + station_id: str = "" + + +@dataclass +class DataType(MetadataEntity): + """Data type metadata""" + units: Optional[str] = None + min_value: Optional[float] = None + max_value: Optional[float] = None + default_value: Optional[float] = None + + +class TagMetadataManager: + """ + Tag-based metadata management system + + Features: + - User-defined tags and attributes + - System-suggested core tags + - Flexible search and filtering + - No industry-specific assumptions + """ + + def __init__(self): + self.stations: Dict[str, Station] = {} + self.equipment: Dict[str, Equipment] = {} + self.data_types: Dict[str, DataType] = {} + self.all_tags: Set[str] = set() + + # Core suggested tags (users can ignore these) + self._initialize_core_tags() + + logger.info("TagMetadataManager initialized with tag-based approach") + + def _initialize_core_tags(self): + """Initialize core suggested tags for consistency""" + core_tags = { + # Function tags + "control", "monitoring", "safety", "diagnostic", "optimization", + + # Signal type tags + "setpoint", "measurement", "status", "alarm", "command", "feedback", + + # Equipment type tags + "pump", "valve", "motor", "sensor", "controller", "actuator", + + # Location tags + "primary", "secondary", "backup", "emergency", "remote", "local", + + # Status tags + "active", "inactive", "maintenance", "fault", "healthy" + } + + self.all_tags.update(core_tags) + + def add_station(self, + name: str, + tags: List[str] = None, + attributes: Dict[str, Any] = None, + description: str = None, + station_id: str = None) -> str: + """Add a new station""" + station_id = station_id or f"station_{uuid.uuid4().hex[:8]}" + + station = Station( + id=station_id, + name=name, + tags=tags or [], + attributes=attributes or {}, + description=description + ) + + self.stations[station_id] = station + self.all_tags.update(station.tags) + + logger.info(f"Added station: {station_id} with tags: {station.tags}") + return station_id + + def add_equipment(self, + name: str, + station_id: str, + tags: List[str] = None, + attributes: Dict[str, Any] = None, + description: str = None, + equipment_id: str = None) -> str: + """Add new equipment to a station""" + if station_id not in self.stations: + raise ValueError(f"Station {station_id} does not exist") + + equipment_id = equipment_id or f"equipment_{uuid.uuid4().hex[:8]}" + + equipment = Equipment( + id=equipment_id, + name=name, + station_id=station_id, + tags=tags or [], + attributes=attributes or {}, + description=description + ) + + self.equipment[equipment_id] = equipment + self.all_tags.update(equipment.tags) + + logger.info(f"Added equipment: {equipment_id} to station {station_id}") + return equipment_id + + def add_data_type(self, + name: str, + tags: List[str] = None, + attributes: Dict[str, Any] = None, + description: str = None, + units: str = None, + min_value: float = None, + max_value: float = None, + default_value: float = None, + data_type_id: str = None) -> str: + """Add a new data type""" + data_type_id = data_type_id or f"datatype_{uuid.uuid4().hex[:8]}" + + data_type = DataType( + id=data_type_id, + name=name, + tags=tags or [], + attributes=attributes or {}, + description=description, + units=units, + min_value=min_value, + max_value=max_value, + default_value=default_value + ) + + self.data_types[data_type_id] = data_type + self.all_tags.update(data_type.tags) + + logger.info(f"Added data type: {data_type_id} with tags: {data_type.tags}") + return data_type_id + + def get_stations_by_tags(self, tags: List[str]) -> List[Station]: + """Get stations that have ALL specified tags""" + return [ + station for station in self.stations.values() + if all(tag in station.tags for tag in tags) + ] + + def get_equipment_by_tags(self, tags: List[str], station_id: str = None) -> List[Equipment]: + """Get equipment that has ALL specified tags""" + equipment_list = self.equipment.values() + + if station_id: + equipment_list = [eq for eq in equipment_list if eq.station_id == station_id] + + return [ + equipment for equipment in equipment_list + if all(tag in equipment.tags for tag in tags) + ] + + def get_data_types_by_tags(self, tags: List[str]) -> List[DataType]: + """Get data types that have ALL specified tags""" + return [ + data_type for data_type in self.data_types.values() + if all(tag in data_type.tags for tag in tags) + ] + + def search_by_tags(self, tags: List[str]) -> Dict[str, List[Any]]: + """Search across all entities by tags""" + return { + "stations": self.get_stations_by_tags(tags), + "equipment": self.get_equipment_by_tags(tags), + "data_types": self.get_data_types_by_tags(tags) + } + + def get_suggested_tags(self) -> List[str]: + """Get all available tags (core + user-defined)""" + return sorted(list(self.all_tags)) + + def get_metadata_summary(self) -> Dict[str, Any]: + """Get summary of all metadata""" + return { + "stations_count": len(self.stations), + "equipment_count": len(self.equipment), + "data_types_count": len(self.data_types), + "total_tags": len(self.all_tags), + "suggested_tags": self.get_suggested_tags(), + "stations": [asdict(station) for station in self.stations.values()], + "equipment": [asdict(eq) for eq in self.equipment.values()], + "data_types": [asdict(dt) for dt in self.data_types.values()] + } + + def add_custom_tag(self, tag: str): + """Add a custom tag to the system""" + if tag and tag.strip(): + self.all_tags.add(tag.strip().lower()) + logger.info(f"Added custom tag: {tag}") + + def remove_tag_from_entity(self, entity_type: str, entity_id: str, tag: str): + """Remove a tag from a specific entity""" + entity_map = { + "station": self.stations, + "equipment": self.equipment, + "data_type": self.data_types + } + + if entity_type not in entity_map: + raise ValueError(f"Invalid entity type: {entity_type}") + + entity = entity_map[entity_type].get(entity_id) + if not entity: + raise ValueError(f"{entity_type} {entity_id} not found") + + if tag in entity.tags: + entity.tags.remove(tag) + logger.info(f"Removed tag '{tag}' from {entity_type} {entity_id}") + + def export_metadata(self) -> Dict[str, Any]: + """Export all metadata for backup/transfer""" + return { + "stations": {id: asdict(station) for id, station in self.stations.items()}, + "equipment": {id: asdict(eq) for id, eq in self.equipment.items()}, + "data_types": {id: asdict(dt) for id, dt in self.data_types.items()}, + "all_tags": list(self.all_tags) + } + + def import_metadata(self, data: Dict[str, Any]): + """Import metadata from backup""" + try: + # Clear existing data + self.stations.clear() + self.equipment.clear() + self.data_types.clear() + self.all_tags.clear() + + # Import stations + for station_id, station_data in data.get("stations", {}).items(): + self.stations[station_id] = Station(**station_data) + + # Import equipment + for eq_id, eq_data in data.get("equipment", {}).items(): + self.equipment[eq_id] = Equipment(**eq_data) + + # Import data types + for dt_id, dt_data in data.get("data_types", {}).items(): + self.data_types[dt_id] = DataType(**dt_data) + + # Import tags + self.all_tags.update(data.get("all_tags", [])) + + logger.info("Successfully imported metadata") + + except Exception as e: + logger.error(f"Failed to import metadata: {str(e)}") + raise + + +# Global instance +tag_metadata_manager = TagMetadataManager() \ No newline at end of file diff --git a/src/dashboard/api.py b/src/dashboard/api.py index 81986bb..931c35a 100644 --- a/src/dashboard/api.py +++ b/src/dashboard/api.py @@ -12,10 +12,10 @@ from pydantic import BaseModel, ValidationError from config.settings import Settings from .configuration_manager import ( - configuration_manager, OPCUAConfig, ModbusTCPConfig, PumpStationConfig, - PumpConfig, SafetyLimitsConfig, DataPointMapping, ProtocolType, ProtocolMapping + configuration_manager, OPCUAConfig, ModbusTCPConfig, DataPointMapping, ProtocolType, ProtocolMapping ) from src.discovery.protocol_discovery_persistent import persistent_discovery_service, DiscoveryStatus, DiscoveredEndpoint +from src.core.tag_metadata_manager import tag_metadata_manager from datetime import datetime logger = logging.getLogger(__name__) @@ -218,44 +218,7 @@ async def configure_modbus_tcp_protocol(config: ModbusTCPConfig): logger.error(f"Error configuring Modbus TCP protocol: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to configure Modbus TCP protocol: {str(e)}") -@dashboard_router.post("/configure/station") -async def configure_pump_station(station: PumpStationConfig): - """Configure a pump station""" - try: - success = configuration_manager.add_pump_station(station) - if success: - return {"success": True, "message": f"Pump station {station.name} configured successfully"} - else: - raise HTTPException(status_code=400, detail="Failed to configure pump station") - except Exception as e: - logger.error(f"Error configuring pump station: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to configure pump station: {str(e)}") -@dashboard_router.post("/configure/pump") -async def configure_pump(pump: PumpConfig): - """Configure a pump""" - try: - success = configuration_manager.add_pump(pump) - if success: - return {"success": True, "message": f"Pump {pump.name} configured successfully"} - else: - raise HTTPException(status_code=400, detail="Failed to configure pump") - except Exception as e: - logger.error(f"Error configuring pump: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to configure pump: {str(e)}") - -@dashboard_router.post("/configure/safety-limits") -async def configure_safety_limits(limits: SafetyLimitsConfig): - """Configure safety limits for a pump""" - try: - success = configuration_manager.set_safety_limits(limits) - if success: - return {"success": True, "message": f"Safety limits configured for pump {limits.pump_id}"} - else: - raise HTTPException(status_code=400, detail="Failed to configure safety limits") - except Exception as e: - logger.error(f"Error configuring safety limits: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to configure safety limits: {str(e)}") @dashboard_router.post("/configure/data-mapping") async def configure_data_mapping(mapping: DataPointMapping): @@ -830,13 +793,13 @@ async def export_signals(): async def get_protocol_mappings( protocol_type: Optional[str] = None, station_id: Optional[str] = None, - pump_id: Optional[str] = None + equipment_id: Optional[str] = None ): """Get protocol mappings with optional filtering""" try: # Convert protocol_type string to enum if provided protocol_enum = None - if protocol_type: + if protocol_type and protocol_type != "all": try: protocol_enum = ProtocolType(protocol_type) except ValueError: @@ -845,7 +808,7 @@ async def get_protocol_mappings( mappings = configuration_manager.get_protocol_mappings( protocol_type=protocol_enum, station_id=station_id, - pump_id=pump_id + equipment_id=equipment_id ) return { @@ -873,14 +836,19 @@ async def create_protocol_mapping(mapping_data: dict): # Create ProtocolMapping object import uuid mapping = ProtocolMapping( - id=mapping_data.get("id") or f"{mapping_data.get('protocol_type')}_{mapping_data.get('station_id', 'unknown')}_{mapping_data.get('pump_id', 'unknown')}_{uuid.uuid4().hex[:8]}", + id=mapping_data.get("id") or f"{mapping_data.get('protocol_type')}_{mapping_data.get('station_id', 'unknown')}_{mapping_data.get('equipment_id', 'unknown')}_{uuid.uuid4().hex[:8]}", protocol_type=protocol_enum, station_id=mapping_data.get("station_id"), - pump_id=mapping_data.get("pump_id"), - data_type=mapping_data.get("data_type"), + equipment_id=mapping_data.get("equipment_id"), + data_type_id=mapping_data.get("data_type_id"), protocol_address=mapping_data.get("protocol_address"), db_source=mapping_data.get("db_source"), transformation_rules=mapping_data.get("transformation_rules", []), + preprocessing_enabled=mapping_data.get("preprocessing_enabled", False), + preprocessing_rules=mapping_data.get("preprocessing_rules", []), + min_output_value=mapping_data.get("min_output_value"), + max_output_value=mapping_data.get("max_output_value"), + default_output_value=mapping_data.get("default_output_value"), modbus_config=mapping_data.get("modbus_config"), opcua_config=mapping_data.get("opcua_config") ) @@ -923,8 +891,8 @@ async def update_protocol_mapping(mapping_id: str, mapping_data: dict): id=mapping_id, # Use the ID from URL protocol_type=protocol_enum or ProtocolType(mapping_data.get("protocol_type")), station_id=mapping_data.get("station_id"), - pump_id=mapping_data.get("pump_id"), - data_type=mapping_data.get("data_type"), + equipment_id=mapping_data.get("equipment_id"), + data_type_id=mapping_data.get("data_type_id"), protocol_address=mapping_data.get("protocol_address"), db_source=mapping_data.get("db_source"), transformation_rules=mapping_data.get("transformation_rules", []), @@ -971,6 +939,181 @@ async def delete_protocol_mapping(mapping_id: str): # Protocol Discovery API Endpoints +# Tag-Based Metadata API Endpoints + +@dashboard_router.get("/metadata/summary") +async def get_metadata_summary(): + """Get tag-based metadata summary""" + try: + summary = tag_metadata_manager.get_metadata_summary() + return { + "success": True, + "summary": summary + } + except Exception as e: + logger.error(f"Error getting metadata summary: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get metadata summary: {str(e)}") + +@dashboard_router.get("/metadata/stations") +async def get_stations(tags: Optional[str] = None): + """Get stations, optionally filtered by tags (comma-separated)""" + try: + tag_list = tags.split(",") if tags else [] + stations = tag_metadata_manager.get_stations_by_tags(tag_list) + return { + "success": True, + "stations": stations, + "count": len(stations) + } + except Exception as e: + logger.error(f"Error getting stations: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get stations: {str(e)}") + +@dashboard_router.get("/metadata/equipment") +async def get_equipment(station_id: Optional[str] = None, tags: Optional[str] = None): + """Get equipment, optionally filtered by station and tags""" + try: + tag_list = tags.split(",") if tags else [] + equipment = tag_metadata_manager.get_equipment_by_tags(tag_list, station_id) + return { + "success": True, + "equipment": equipment, + "count": len(equipment) + } + except Exception as e: + logger.error(f"Error getting equipment: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get equipment: {str(e)}") + +@dashboard_router.get("/metadata/data-types") +async def get_data_types(tags: Optional[str] = None): + """Get data types, optionally filtered by tags""" + try: + tag_list = tags.split(",") if tags else [] + data_types = tag_metadata_manager.get_data_types_by_tags(tag_list) + return { + "success": True, + "data_types": data_types, + "count": len(data_types) + } + except Exception as e: + logger.error(f"Error getting data types: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get data types: {str(e)}") + +@dashboard_router.get("/metadata/tags") +async def get_suggested_tags(): + """Get all available tags (core + user-defined)""" + try: + tags = tag_metadata_manager.get_suggested_tags() + return { + "success": True, + "tags": tags, + "count": len(tags) + } + except Exception as e: + logger.error(f"Error getting tags: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get tags: {str(e)}") + +@dashboard_router.post("/metadata/stations") +async def create_station(station_data: dict): + """Create a new station with tags""" + try: + station_id = tag_metadata_manager.add_station( + name=station_data.get("name"), + tags=station_data.get("tags", []), + attributes=station_data.get("attributes", {}), + description=station_data.get("description"), + station_id=station_data.get("id") + ) + return { + "success": True, + "station_id": station_id, + "message": "Station created successfully" + } + except Exception as e: + logger.error(f"Error creating station: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to create station: {str(e)}") + +@dashboard_router.post("/metadata/equipment") +async def create_equipment(equipment_data: dict): + """Create new equipment with tags""" + try: + equipment_id = tag_metadata_manager.add_equipment( + name=equipment_data.get("name"), + station_id=equipment_data.get("station_id"), + tags=equipment_data.get("tags", []), + attributes=equipment_data.get("attributes", {}), + description=equipment_data.get("description"), + equipment_id=equipment_data.get("id") + ) + return { + "success": True, + "equipment_id": equipment_id, + "message": "Equipment created successfully" + } + except Exception as e: + logger.error(f"Error creating equipment: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to create equipment: {str(e)}") + +@dashboard_router.post("/metadata/data-types") +async def create_data_type(data_type_data: dict): + """Create new data type with tags""" + try: + data_type_id = tag_metadata_manager.add_data_type( + name=data_type_data.get("name"), + tags=data_type_data.get("tags", []), + attributes=data_type_data.get("attributes", {}), + description=data_type_data.get("description"), + units=data_type_data.get("units"), + min_value=data_type_data.get("min_value"), + max_value=data_type_data.get("max_value"), + default_value=data_type_data.get("default_value"), + data_type_id=data_type_data.get("id") + ) + return { + "success": True, + "data_type_id": data_type_id, + "message": "Data type created successfully" + } + except Exception as e: + logger.error(f"Error creating data type: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to create data type: {str(e)}") + +@dashboard_router.post("/metadata/tags") +async def add_custom_tag(tag_data: dict): + """Add a custom tag to the system""" + try: + tag = tag_data.get("tag") + if not tag: + raise HTTPException(status_code=400, detail="Tag is required") + + tag_metadata_manager.add_custom_tag(tag) + return { + "success": True, + "message": f"Tag '{tag}' added successfully" + } + except Exception as e: + logger.error(f"Error adding tag: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to add tag: {str(e)}") + +@dashboard_router.get("/metadata/search") +async def search_metadata(tags: str): + """Search across all metadata entities by tags""" + try: + if not tags: + raise HTTPException(status_code=400, detail="Tags parameter is required") + + tag_list = tags.split(",") + results = tag_metadata_manager.search_by_tags(tag_list) + return { + "success": True, + "search_tags": tag_list, + "results": results + } + except Exception as e: + logger.error(f"Error searching metadata: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to search metadata: {str(e)}") + + @dashboard_router.get("/discovery/status") async def get_discovery_status(): """Get current discovery service status""" @@ -1097,7 +1240,7 @@ async def get_recent_discoveries(): @dashboard_router.post("/discovery/apply/{scan_id}") -async def apply_discovery_results(scan_id: str, station_id: str, pump_id: str, data_type: str, db_source: str): +async def apply_discovery_results(scan_id: str, station_id: str, equipment_id: str, data_type_id: str, db_source: str): """Apply discovered endpoints as protocol mappings""" try: result = persistent_discovery_service.get_scan_result(scan_id) @@ -1114,15 +1257,29 @@ async def apply_discovery_results(scan_id: str, station_id: str, pump_id: str, d for endpoint in result.get("discovered_endpoints", []): try: # Create protocol mapping from discovered endpoint - mapping_id = f"{endpoint.get('device_id')}_{data_type}" + mapping_id = f"{endpoint.get('device_id')}_{data_type_id}" + + # Convert protocol types to match configuration manager expectations + protocol_type = endpoint.get("protocol_type") + if protocol_type == "opc_ua": + protocol_type = "opcua" + + # Convert addresses based on protocol type + protocol_address = endpoint.get("address") + if protocol_type == "modbus_tcp": + # For Modbus TCP, use a default register address since IP is not valid + protocol_address = "40001" # Default holding register + elif protocol_type == "opcua": + # For OPC UA, construct a proper node ID + protocol_address = f"ns=2;s={endpoint.get('device_name', 'Device').replace(' ', '_')}" protocol_mapping = ProtocolMapping( id=mapping_id, station_id=station_id, - pump_id=pump_id, - protocol_type=endpoint.get("protocol_type"), - protocol_address=endpoint.get("address"), - data_type=data_type, + equipment_id=equipment_id, + protocol_type=protocol_type, + protocol_address=protocol_address, + data_type_id=data_type_id, db_source=db_source ) @@ -1167,8 +1324,8 @@ async def validate_protocol_mapping(mapping_id: str, mapping_data: dict): id=mapping_id, protocol_type=protocol_enum, station_id=mapping_data.get("station_id"), - pump_id=mapping_data.get("pump_id"), - data_type=mapping_data.get("data_type"), + equipment_id=mapping_data.get("equipment_id"), + data_type_id=mapping_data.get("data_type_id"), protocol_address=mapping_data.get("protocol_address"), db_source=mapping_data.get("db_source"), transformation_rules=mapping_data.get("transformation_rules", []), diff --git a/src/dashboard/configuration_manager.py b/src/dashboard/configuration_manager.py index 1b94d2f..f497917 100644 --- a/src/dashboard/configuration_manager.py +++ b/src/dashboard/configuration_manager.py @@ -52,57 +52,7 @@ class ModbusTCPConfig(SCADAProtocolConfig): 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""" @@ -118,12 +68,19 @@ class ProtocolMapping(BaseModel): id: str protocol_type: ProtocolType station_id: str - pump_id: str - data_type: str # setpoint, status, power, flow, level, safety, etc. + equipment_id: str + data_type_id: str protocol_address: str # register address or OPC UA node db_source: str # database table and column transformation_rules: List[Dict[str, Any]] = [] + # Signal preprocessing configuration + preprocessing_enabled: bool = False + preprocessing_rules: List[Dict[str, Any]] = [] + min_output_value: Optional[float] = None + max_output_value: Optional[float] = None + default_output_value: Optional[float] = None + # Protocol-specific configurations modbus_config: Optional[Dict[str, Any]] = None opcua_config: Optional[Dict[str, Any]] = None @@ -134,6 +91,36 @@ class ProtocolMapping(BaseModel): raise ValueError("Mapping ID must be alphanumeric with underscores") return v + @validator('station_id') + def validate_station_id(cls, v): + """Validate that station exists in tag metadata system""" + from src.core.tag_metadata_manager import tag_metadata_manager + if v and v not in tag_metadata_manager.stations: + raise ValueError(f"Station '{v}' does not exist in tag metadata system") + return v + + @validator('equipment_id') + def validate_equipment_id(cls, v, values): + """Validate that equipment exists in tag metadata system and belongs to station""" + from src.core.tag_metadata_manager import tag_metadata_manager + if v and v not in tag_metadata_manager.equipment: + raise ValueError(f"Equipment '{v}' does not exist in tag metadata system") + + # Validate equipment belongs to station + if 'station_id' in values and values['station_id']: + equipment = tag_metadata_manager.equipment.get(v) + if equipment and equipment.station_id != values['station_id']: + raise ValueError(f"Equipment '{v}' does not belong to station '{values['station_id']}'") + return v + + @validator('data_type_id') + def validate_data_type_id(cls, v): + """Validate that data type exists in tag metadata system""" + from src.core.tag_metadata_manager import tag_metadata_manager + if v and v not in tag_metadata_manager.data_types: + raise ValueError(f"Data type '{v}' does not exist in tag metadata system") + return v + @validator('protocol_address') def validate_protocol_address(cls, v, values): if 'protocol_type' in values: @@ -158,12 +145,58 @@ class ProtocolMapping(BaseModel): if not v.startswith(('http://', 'https://')): raise ValueError("REST API endpoint must start with 'http://' or 'https://'") return v + + def apply_preprocessing(self, value: float) -> float: + """Apply preprocessing rules to a value""" + if not self.preprocessing_enabled: + return value + + processed_value = value + + for rule in self.preprocessing_rules: + rule_type = rule.get('type') + params = rule.get('parameters', {}) + + if rule_type == 'scale': + processed_value *= params.get('factor', 1.0) + elif rule_type == 'offset': + processed_value += params.get('offset', 0.0) + elif rule_type == 'clamp': + min_val = params.get('min', float('-inf')) + max_val = params.get('max', float('inf')) + processed_value = max(min_val, min(processed_value, max_val)) + elif rule_type == 'linear_map': + # Map from [input_min, input_max] to [output_min, output_max] + input_min = params.get('input_min', 0.0) + input_max = params.get('input_max', 1.0) + output_min = params.get('output_min', 0.0) + output_max = params.get('output_max', 1.0) + + if input_max == input_min: + processed_value = output_min + else: + normalized = (processed_value - input_min) / (input_max - input_min) + processed_value = output_min + normalized * (output_max - output_min) + elif rule_type == 'deadband': + # Apply deadband to prevent oscillation + center = params.get('center', 0.0) + width = params.get('width', 0.0) + if abs(processed_value - center) <= width: + processed_value = center + + # Apply final output limits + if self.min_output_value is not None: + processed_value = max(self.min_output_value, processed_value) + if self.max_output_value is not None: + processed_value = min(self.max_output_value, processed_value) + + return processed_value class HardwareDiscoveryResult(BaseModel): """Result from hardware auto-discovery""" success: bool - discovered_stations: List[PumpStationConfig] = [] - discovered_pumps: List[PumpConfig] = [] + discovered_stations: List[Dict[str, Any]] = [] + discovered_pumps: List[Dict[str, Any]] = [] errors: List[str] = [] warnings: List[str] = [] @@ -172,9 +205,6 @@ class ConfigurationManager: 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 @@ -187,11 +217,11 @@ class ConfigurationManager: """Load protocol mappings from database""" try: query = """ - SELECT mapping_id, station_id, pump_id, protocol_type, - protocol_address, data_type, db_source, enabled + SELECT mapping_id, station_id, equipment_id, protocol_type, + protocol_address, data_type_id, db_source, enabled FROM protocol_mappings WHERE enabled = true - ORDER BY station_id, pump_id, protocol_type + ORDER BY station_id, equipment_id, protocol_type """ results = self.db_client.execute_query(query) @@ -205,10 +235,10 @@ class ConfigurationManager: mapping = ProtocolMapping( id=row['mapping_id'], station_id=row['station_id'], - pump_id=row['pump_id'], + equipment_id=row['equipment_id'], protocol_type=protocol_type, protocol_address=row['protocol_address'], - data_type=row['data_type'], + data_type_id=row['data_type_id'], db_source=row['db_source'] ) self.protocol_mappings.append(mapping) @@ -230,44 +260,7 @@ class ConfigurationManager: 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""" @@ -307,14 +300,14 @@ class ConfigurationManager: 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) + (mapping_id, station_id, equipment_id, protocol_type, protocol_address, data_type_id, db_source, created_by, enabled) + VALUES (:mapping_id, :station_id, :equipment_id, :protocol_type, :protocol_address, :data_type_id, :db_source, :created_by, :enabled) ON CONFLICT (mapping_id) DO UPDATE SET station_id = EXCLUDED.station_id, - pump_id = EXCLUDED.pump_id, + equipment_id = EXCLUDED.equipment_id, protocol_type = EXCLUDED.protocol_type, protocol_address = EXCLUDED.protocol_address, - data_type = EXCLUDED.data_type, + data_type_id = EXCLUDED.data_type_id, db_source = EXCLUDED.db_source, enabled = EXCLUDED.enabled, updated_at = CURRENT_TIMESTAMP @@ -322,10 +315,10 @@ class ConfigurationManager: params = { 'mapping_id': mapping.id, 'station_id': mapping.station_id, - 'pump_id': mapping.pump_id, + 'equipment_id': mapping.equipment_id, 'protocol_type': mapping.protocol_type.value, 'protocol_address': mapping.protocol_address, - 'data_type': mapping.data_type, + 'data_type_id': mapping.data_type_id, 'db_source': mapping.db_source, 'created_by': 'dashboard', 'enabled': True @@ -333,7 +326,7 @@ class ConfigurationManager: 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}") + logger.info(f"Added protocol mapping {mapping.id}: {mapping.protocol_type} for {mapping.station_id}/{mapping.equipment_id}") return True except Exception as e: logger.error(f"Failed to add protocol mapping {mapping.id}: {str(e)}") @@ -342,8 +335,8 @@ class ConfigurationManager: 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""" + equipment_id: Optional[str] = None) -> List[ProtocolMapping]: + """Get mappings filtered by protocol/station/equipment""" filtered_mappings = self.protocol_mappings.copy() if protocol_type: @@ -352,8 +345,8 @@ class ConfigurationManager: 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] + if equipment_id: + filtered_mappings = [m for m in filtered_mappings if m.equipment_id == equipment_id] return filtered_mappings @@ -373,10 +366,10 @@ class ConfigurationManager: query = """ UPDATE protocol_mappings SET station_id = :station_id, - pump_id = :pump_id, + equipment_id = :equipment_id, protocol_type = :protocol_type, protocol_address = :protocol_address, - data_type = :data_type, + data_type_id = :data_type_id, db_source = :db_source, updated_at = CURRENT_TIMESTAMP WHERE mapping_id = :mapping_id @@ -384,10 +377,10 @@ class ConfigurationManager: params = { 'mapping_id': mapping_id, 'station_id': updated_mapping.station_id, - 'pump_id': updated_mapping.pump_id, + 'equipment_id': updated_mapping.equipment_id, 'protocol_type': updated_mapping.protocol_type.value, 'protocol_address': updated_mapping.protocol_address, - 'data_type': updated_mapping.data_type, + 'data_type_id': updated_mapping.data_type_id, 'db_source': updated_mapping.db_source } self.db_client.execute(query, params) @@ -445,7 +438,7 @@ class ConfigurationManager: 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}") + errors.append(f"Modbus address {mapping.protocol_address} already used by {existing.station_id}/{existing.equipment_id}") break except ValueError: @@ -461,7 +454,7 @@ class ConfigurationManager: 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}") + errors.append(f"OPC UA node {mapping.protocol_address} already used by {existing.station_id}/{existing.equipment_id}") break elif mapping.protocol_type == ProtocolType.MODBUS_RTU: @@ -476,7 +469,7 @@ class ConfigurationManager: 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}") + errors.append(f"Modbus RTU address {mapping.protocol_address} already used by {existing.station_id}/{existing.equipment_id}") break except ValueError: @@ -492,7 +485,7 @@ class ConfigurationManager: 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}") + errors.append(f"REST API endpoint {mapping.protocol_address} already used by {existing.station_id}/{existing.equipment_id}") break # Check database source format @@ -517,25 +510,25 @@ class ConfigurationManager: 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 - ) + mock_station = { + "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 - ) + mock_pump = { + "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 @@ -592,9 +585,6 @@ class ConfigurationManager: # 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) } @@ -605,9 +595,6 @@ class ConfigurationManager: """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] } @@ -617,9 +604,6 @@ class ConfigurationManager: 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() @@ -634,21 +618,6 @@ class ConfigurationManager: 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) diff --git a/src/dashboard/templates.py b/src/dashboard/templates.py index c210989..6ba230d 100644 --- a/src/dashboard/templates.py +++ b/src/dashboard/templates.py @@ -564,7 +564,7 @@ DASHBOARD_HTML = """ ID Protocol Station - Pump + Equipment Data Type Protocol Address Database Source @@ -599,25 +599,25 @@ DASHBOARD_HTML = """
- - -
-
- - -
-
- - + + Stations will be loaded from tag metadata system +
+
+ + + Equipment will be loaded based on selected station +
+
+ + + Data types will be loaded from tag metadata system
diff --git a/static/discovery.js b/static/discovery.js index b3f5ce2..8f1a3d6 100644 --- a/static/discovery.js +++ b/static/discovery.js @@ -179,10 +179,6 @@ class ProtocolDiscovery {
Discovery service ready - ${status.total_discovered_endpoints > 0 ? - `- ${status.total_discovered_endpoints} endpoints discovered` : - '' - }
`; scanButton?.removeAttribute('disabled'); @@ -291,31 +287,29 @@ class ProtocolDiscovery { // Get station and pump info from form or prompt const stationId = document.getElementById('station-id')?.value || 'station_001'; const pumpId = document.getElementById('pump-id')?.value || 'pump_001'; - const dataType = document.getElementById('data-type')?.value || 'setpoint'; - const dbSource = document.getElementById('db-source')?.value || 'frequency_hz'; + const dataType = document.getElementById('data-type')?.value || 'pressure'; + const dbSource = document.getElementById('db-source')?.value || 'influxdb'; try { - const response = await fetch(`/api/v1/dashboard/discovery/apply/${this.currentScanId}`, { + const response = await fetch(`/api/v1/dashboard/discovery/apply/${this.currentScanId}?station_id=${stationId}&pump_id=${pumpId}&data_type=${dataType}&db_source=${dbSource}`, { method: 'POST', headers: { 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - station_id: stationId, - pump_id: pumpId, - data_type: dataType, - db_source: dbSource - }) + } }); const result = await response.json(); if (result.success) { - this.showNotification(`Successfully created ${result.created_mappings.length} protocol mappings`, 'success'); - - // Refresh protocol mappings grid - if (window.protocolMappingGrid) { - window.protocolMappingGrid.loadProtocolMappings(); + if (result.created_mappings.length > 0) { + this.showNotification(`Successfully created ${result.created_mappings.length} protocol mappings from discovery results`, 'success'); + + // Refresh protocol mappings grid + if (window.protocolMappingGrid) { + window.protocolMappingGrid.loadProtocolMappings(); + } + } else { + this.showNotification('No protocol mappings were created. Check the discovery results for compatible endpoints.', 'warning'); } } else { throw new Error(result.detail || 'Failed to apply discovery results'); @@ -329,15 +323,163 @@ class ProtocolDiscovery { /** * Use discovered endpoint in protocol form */ - useDiscoveredEndpoint(endpointId) { - // This would fetch the specific endpoint details and populate the form - // For now, we'll just show a notification - this.showNotification(`Endpoint ${endpointId} selected for protocol mapping`, 'info'); + async useDiscoveredEndpoint(endpointId) { + try { + // Get current scan results to find the endpoint + if (!this.currentScanId) { + this.showNotification('No discovery results available', 'warning'); + return; + } + + const response = await fetch(`/api/v1/dashboard/discovery/results/${this.currentScanId}`); + const result = await response.json(); + + if (!result.success) { + throw new Error('Failed to fetch discovery results'); + } + + // Find the specific endpoint + const endpoint = result.discovered_endpoints.find(ep => ep.device_id === endpointId); + if (!endpoint) { + this.showNotification(`Endpoint ${endpointId} not found in current scan`, 'warning'); + return; + } + + // Populate protocol mapping form with endpoint data + this.populateProtocolForm(endpoint); + + // Switch to protocol mapping tab + this.switchToProtocolMappingTab(); + + this.showNotification(`Endpoint ${endpoint.device_name || endpointId} selected for protocol mapping`, 'success'); + + } catch (error) { + console.error('Error using discovered endpoint:', error); + this.showNotification(`Failed to use endpoint: ${error.message}`, 'error'); + } + } + + /** + * Populate protocol mapping form with endpoint data + */ + populateProtocolForm(endpoint) { + // Create a new protocol mapping ID + const mappingId = `${endpoint.device_id}_${endpoint.protocol_type}`; - // In a real implementation, we would: - // 1. Fetch endpoint details - // 2. Populate protocol form fields - // 3. Switch to protocol mapping tab + // Set form values (these would be used when creating a new mapping) + const formData = { + mapping_id: mappingId, + protocol_type: endpoint.protocol_type === 'opc_ua' ? 'opcua' : endpoint.protocol_type, + protocol_address: this.getDefaultProtocolAddress(endpoint), + device_name: endpoint.device_name || endpoint.device_id, + device_address: endpoint.address, + device_port: endpoint.port || '', + station_id: 'station_001', // Default station ID + equipment_id: 'equipment_001', // Default equipment ID + data_type_id: 'datatype_001' // Default data type ID + }; + + // Store form data for later use + this.selectedEndpoint = formData; + + // Show form data in console for debugging + console.log('Protocol form populated with:', formData); + + // Auto-populate the protocol mapping form + this.autoPopulateProtocolForm(formData); + } + + /** + * Auto-populate the protocol mapping form with endpoint data + */ + autoPopulateProtocolForm(formData) { + // First, open the "Add New Mapping" modal + this.openAddMappingModal(); + + // Wait a moment for the modal to open, then populate fields + setTimeout(() => { + // Find and populate form fields in the modal + const mappingIdField = document.getElementById('mapping-id'); + const protocolTypeField = document.getElementById('protocol-type'); + const protocolAddressField = document.getElementById('protocol-address'); + const deviceNameField = document.getElementById('device-name'); + const deviceAddressField = document.getElementById('device-address'); + const devicePortField = document.getElementById('device-port'); + const stationIdField = document.getElementById('station-id'); + const equipmentIdField = document.getElementById('equipment-id'); + const dataTypeIdField = document.getElementById('data-type-id'); + + if (mappingIdField) mappingIdField.value = formData.mapping_id; + if (protocolTypeField) protocolTypeField.value = formData.protocol_type; + if (protocolAddressField) protocolAddressField.value = formData.protocol_address; + if (deviceNameField) deviceNameField.value = formData.device_name; + if (deviceAddressField) deviceAddressField.value = formData.device_address; + if (devicePortField) devicePortField.value = formData.device_port; + if (stationIdField) stationIdField.value = formData.station_id; + if (equipmentIdField) equipmentIdField.value = formData.equipment_id; + if (dataTypeIdField) dataTypeIdField.value = formData.data_type_id; + + // Show success message + this.showNotification(`Protocol form populated with ${formData.device_name}. Please review and complete any missing information.`, 'success'); + }, 100); + } + + /** + * Open the "Add New Mapping" modal + */ + openAddMappingModal() { + // Look for the showAddMappingModal function or button click + if (typeof showAddMappingModal === 'function') { + showAddMappingModal(); + } else { + // Try to find and click the "Add New Mapping" button + const addButton = document.querySelector('button[onclick*="showAddMappingModal"]'); + if (addButton) { + addButton.click(); + } else { + // Fallback: show a message to manually open the modal + this.showNotification('Please click "Add New Mapping" to create a protocol mapping with the discovered endpoint data.', 'info'); + } + } + } + + /** + * Get default protocol address based on endpoint type + */ + getDefaultProtocolAddress(endpoint) { + const protocolType = endpoint.protocol_type; + const deviceName = endpoint.device_name || endpoint.device_id; + + switch (protocolType) { + case 'modbus_tcp': + return '40001'; // Default holding register + case 'opc_ua': + return `ns=2;s=${deviceName.replace(/\s+/g, '_')}`; + case 'rest_api': + return `http://${endpoint.address}${endpoint.port ? ':' + endpoint.port : ''}/api/data`; + default: + return endpoint.address; + } + } + + /** + * Switch to protocol mapping tab + */ + switchToProtocolMappingTab() { + // Find and click the protocol mapping tab + const mappingTab = document.querySelector('[data-tab="protocol-mapping"]'); + if (mappingTab) { + mappingTab.click(); + } else { + // Fallback: scroll to protocol mapping section + const mappingSection = document.querySelector('#protocol-mapping-section'); + if (mappingSection) { + mappingSection.scrollIntoView({ behavior: 'smooth' }); + } + } + + // Show guidance message + this.showNotification('Please complete the protocol mapping form with station, pump, and data type information', 'info'); } /** diff --git a/static/protocol_mapping.js b/static/protocol_mapping.js index 03b7663..c1da8d1 100644 --- a/static/protocol_mapping.js +++ b/static/protocol_mapping.js @@ -1,6 +1,100 @@ // Protocol Mapping Functions let currentProtocolFilter = 'all'; let editingMappingId = null; +let tagMetadata = { + stations: [], + equipment: [], + dataTypes: [] +}; + +// Tag Metadata Functions +async function loadTagMetadata() { + try { + // Load stations + const stationsResponse = await fetch('/api/v1/dashboard/metadata/stations'); + const stationsData = await stationsResponse.json(); + if (stationsData.success) { + tagMetadata.stations = stationsData.stations; + populateStationDropdown(); + } + + // Load data types + const dataTypesResponse = await fetch('/api/v1/dashboard/metadata/data-types'); + const dataTypesData = await dataTypesResponse.json(); + if (dataTypesData.success) { + tagMetadata.dataTypes = dataTypesData.data_types; + populateDataTypeDropdown(); + } + + // Load equipment for all stations + const equipmentResponse = await fetch('/api/v1/dashboard/metadata/equipment'); + const equipmentData = await equipmentResponse.json(); + if (equipmentData.success) { + tagMetadata.equipment = equipmentData.equipment; + } + + } catch (error) { + console.error('Error loading tag metadata:', error); + } +} + +function populateStationDropdown() { + const stationSelect = document.getElementById('station_id'); + stationSelect.innerHTML = ''; + + tagMetadata.stations.forEach(station => { + const option = document.createElement('option'); + option.value = station.id; + option.textContent = `${station.name} (${station.id})`; + stationSelect.appendChild(option); + }); +} + +function populateEquipmentDropdown(stationId = null) { + const equipmentSelect = document.getElementById('equipment_id'); + equipmentSelect.innerHTML = ''; + + let filteredEquipment = tagMetadata.equipment; + if (stationId) { + filteredEquipment = tagMetadata.equipment.filter(eq => eq.station_id === stationId); + } + + filteredEquipment.forEach(equipment => { + const option = document.createElement('option'); + option.value = equipment.id; + option.textContent = `${equipment.name} (${equipment.id})`; + equipmentSelect.appendChild(option); + }); +} + +function populateDataTypeDropdown() { + const dataTypeSelect = document.getElementById('data_type_id'); + dataTypeSelect.innerHTML = ''; + + tagMetadata.dataTypes.forEach(dataType => { + const option = document.createElement('option'); + option.value = dataType.id; + option.textContent = `${dataType.name} (${dataType.id})`; + if (dataType.units) { + option.textContent += ` [${dataType.units}]`; + } + dataTypeSelect.appendChild(option); + }); +} + +// Event listener for station selection change +document.addEventListener('DOMContentLoaded', function() { + const stationSelect = document.getElementById('station_id'); + if (stationSelect) { + stationSelect.addEventListener('change', function() { + const stationId = this.value; + populateEquipmentDropdown(stationId); + }); + } + + // Load tag metadata when page loads + loadTagMetadata(); +}); function selectProtocol(protocol) { currentProtocolFilter = protocol; @@ -51,8 +145,8 @@ function displayProtocolMappings(mappings) { ${mapping.id} ${mapping.protocol_type} ${mapping.station_id || '-'} - ${mapping.pump_id || '-'} - ${mapping.data_type} + ${mapping.equipment_id || '-'} + ${mapping.data_type_id || '-'} ${mapping.protocol_address} ${mapping.db_source} @@ -77,9 +171,19 @@ function showEditMappingModal(mapping) { document.getElementById('modal-title').textContent = 'Edit Protocol Mapping'; document.getElementById('mapping_id').value = mapping.id; document.getElementById('protocol_type').value = mapping.protocol_type; - document.getElementById('station_id').value = mapping.station_id || ''; - document.getElementById('pump_id').value = mapping.pump_id || ''; - document.getElementById('data_type').value = mapping.data_type; + + // Set dropdown values + const stationSelect = document.getElementById('station_id'); + const equipmentSelect = document.getElementById('equipment_id'); + const dataTypeSelect = document.getElementById('data_type_id'); + + stationSelect.value = mapping.station_id || ''; + if (mapping.station_id) { + populateEquipmentDropdown(mapping.station_id); + } + equipmentSelect.value = mapping.equipment_id || ''; + dataTypeSelect.value = mapping.data_type_id || ''; + document.getElementById('protocol_address').value = mapping.protocol_address; document.getElementById('db_source').value = mapping.db_source; @@ -181,8 +285,8 @@ function getMappingFormData() { return { protocol_type: document.getElementById('protocol_type').value, station_id: document.getElementById('station_id').value, - pump_id: document.getElementById('pump_id').value, - data_type: document.getElementById('data_type').value, + equipment_id: document.getElementById('equipment_id').value, + data_type_id: document.getElementById('data_type_id').value, protocol_address: document.getElementById('protocol_address').value, db_source: document.getElementById('db_source').value }; diff --git a/test_use_button.html b/test_use_button.html new file mode 100644 index 0000000..b923722 --- /dev/null +++ b/test_use_button.html @@ -0,0 +1,127 @@ + + + + Test Use Button Functionality + + + +

Test Use Button Functionality

+ +
+

Discovered Endpoint

+

Device: Modbus Controller

+

Address: 192.168.1.100:502

+

Protocol: modbus_tcp

+

Node: 40001

+ +
+ +
+

Discovered Endpoint

+

Device: OPC UA Server

+

Address: 192.168.1.101:4840

+

Protocol: opcua

+

Node: ns=2;s=Pressure

+ +
+ + + + + + + \ No newline at end of file diff --git a/test_use_button_workflow.html b/test_use_button_workflow.html new file mode 100644 index 0000000..eadd916 --- /dev/null +++ b/test_use_button_workflow.html @@ -0,0 +1,238 @@ + + + + + + Test Use Button Workflow + + + +
+

Test Use Button Workflow

+

This page tests the "Use" button functionality with the new tag-based metadata system.

+ +
+

Step 1: Simulate Discovery Results

+

Click the button below to simulate discovering a device endpoint:

+ +
+ + + + + + +
+ + + + \ No newline at end of file