""" REST API Server for Calejo Control Adapter. Provides REST endpoints for emergency stop, status monitoring, and setpoint access. """ from typing import Optional, Dict, Any from datetime import datetime import structlog from fastapi import FastAPI, HTTPException, status, Depends, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from src.core.setpoint_manager import SetpointManager from src.core.emergency_stop import EmergencyStopManager from src.core.security import ( SecurityManager, TokenData, UserRole, get_security_manager ) from src.core.tls_manager import get_tls_manager logger = structlog.get_logger() # Security security = HTTPBearer() class LoginRequest(BaseModel): """Request model for user login.""" username: str password: str class LoginResponse(BaseModel): """Response model for successful login.""" access_token: str token_type: str = "bearer" expires_in: int user_id: str username: str role: str permissions: list[str] class UserInfoResponse(BaseModel): """Response model for user information.""" user_id: str username: str email: str role: str permissions: list[str] last_login: Optional[str] def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), security_manager: SecurityManager = Depends(get_security_manager) ) -> TokenData: """Dependency to get current user from JWT token.""" token = credentials.credentials token_data = security_manager.verify_access_token(token) if not token_data: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) return token_data def require_permission(permission: str): """Dependency factory to require specific permission.""" def permission_dependency( token_data: TokenData = Depends(get_current_user), security_manager: SecurityManager = Depends(get_security_manager) ): if not security_manager.check_permission(token_data, permission): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Insufficient permissions. Required: {permission}" ) return token_data return permission_dependency class EmergencyStopRequest(BaseModel): """Request model for emergency stop.""" triggered_by: str reason: str station_id: Optional[str] = None pump_id: Optional[str] = None class EmergencyStopClearRequest(BaseModel): """Request model for emergency stop clearance.""" cleared_by: str notes: str class SetpointResponse(BaseModel): """Response model for setpoint data.""" station_id: str pump_id: str setpoint_hz: Optional[float] control_type: str safety_status: str timestamp: str class SetpointUpdateRequest(BaseModel): """Request model for updating setpoint.""" setpoint_hz: float reason: Optional[str] = None class RESTAPIServer: """REST API Server for Calejo Control Adapter.""" def __init__( self, setpoint_manager: SetpointManager, emergency_stop_manager: EmergencyStopManager, host: str = "0.0.0.0", port: int = 8000 ): self.setpoint_manager = setpoint_manager self.emergency_stop_manager = emergency_stop_manager self.host = host self.port = port # Create FastAPI app self.app = FastAPI( title="Calejo Control API", version="2.0", description="REST API for Calejo Control Adapter with safety framework", docs_url="/docs", redoc_url="/redoc" ) # Add CORS middleware self.app.add_middleware( CORSMiddleware, allow_origins=["*"], # In production, restrict to specific origins allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) self._setup_routes() def _setup_routes(self): """Setup all API routes.""" @self.app.get("/", summary="API Root", tags=["General"]) async def root(): """API root endpoint.""" return { "name": "Calejo Control API", "version": "2.0", "status": "operational", "authentication_required": True } @self.app.get("/health", summary="Health Check", tags=["General"]) async def health_check(): """Health check endpoint.""" return { "status": "healthy", "timestamp": datetime.now().isoformat() } # Authentication endpoints (no authentication required) @self.app.post( "/api/v1/auth/login", summary="User Login", tags=["Authentication"], response_model=LoginResponse ) async def login( request: LoginRequest, security_manager: SecurityManager = Depends(get_security_manager) ): """Authenticate user and return JWT token.""" token = security_manager.authenticate(request.username, request.password) if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password" ) # Get user permissions token_data = security_manager.verify_access_token(token) permissions = security_manager.get_user_permissions(token_data) return LoginResponse( access_token=token, token_type="bearer", expires_in=60, # 60 minutes user_id=token_data.user_id, username=token_data.username, role=token_data.role.value, permissions=permissions ) @self.app.get( "/api/v1/auth/me", summary="Get Current User Info", tags=["Authentication"], response_model=UserInfoResponse ) async def get_current_user_info( token_data: TokenData = Depends(get_current_user), security_manager: SecurityManager = Depends(get_security_manager) ): """Get information about the currently authenticated user.""" # Get user from auth manager user = security_manager.auth_manager.users.get(token_data.user_id) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) permissions = security_manager.get_user_permissions(token_data) return UserInfoResponse( user_id=user.user_id, username=user.username, email=user.email, role=user.role.value, permissions=permissions, last_login=user.last_login.isoformat() if user.last_login else None ) @self.app.get( "/api/v1/setpoints", summary="Get All Setpoints", tags=["Setpoints"], response_model=Dict[str, Dict[str, Optional[float]]] ) async def get_all_setpoints( token_data: TokenData = Depends(require_permission("read_pump_status")) ): """ Get current setpoints for all pumps. Returns dictionary mapping station_id -> pump_id -> setpoint_hz Requires permission: read_pump_status """ try: setpoints = self.setpoint_manager.get_all_current_setpoints() return setpoints except Exception as e: logger.error("failed_to_get_setpoints", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve setpoints" ) @self.app.get( "/api/v1/setpoints/{station_id}/{pump_id}", summary="Get Setpoint for Specific Pump", tags=["Setpoints"], response_model=SetpointResponse ) async def get_pump_setpoint( station_id: str, pump_id: str, token_data: TokenData = Depends(require_permission("read_pump_status")), security_manager: SecurityManager = Depends(get_security_manager) ): """ Get current setpoint for a specific pump. Requires permission: read_pump_status """ try: # Check if user can access this station if not security_manager.can_access_station(token_data, station_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Access denied to station {station_id}" ) setpoint = self.setpoint_manager.get_current_setpoint(station_id, pump_id) # Get pump info for control type pump_info = self.setpoint_manager.discovery.get_pump(station_id, pump_id) control_type = pump_info['control_type'] if pump_info else "UNKNOWN" # Determine safety status safety_status = "normal" if self.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id): safety_status = "emergency_stop" elif self.setpoint_manager.watchdog.is_failsafe_active(station_id, pump_id): safety_status = "failsafe" return SetpointResponse( station_id=station_id, pump_id=pump_id, setpoint_hz=setpoint, control_type=control_type, safety_status=safety_status, timestamp=datetime.now().isoformat() ) except HTTPException: raise except Exception as e: logger.error( "failed_to_get_pump_setpoint", station_id=station_id, pump_id=pump_id, error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to retrieve setpoint for {station_id}/{pump_id}" ) @self.app.put( "/api/v1/setpoints/{station_id}/{pump_id}", summary="Update Setpoint for Specific Pump", tags=["Setpoints"] ) async def update_pump_setpoint( station_id: str, pump_id: str, request: SetpointUpdateRequest, token_data: TokenData = Depends(require_permission("write_pump_setpoint")), security_manager: SecurityManager = Depends(get_security_manager) ): """ Update setpoint for a specific pump. Requires permission: write_pump_setpoint """ try: # Check if user can access this station if not security_manager.can_access_station(token_data, station_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Access denied to station {station_id}" ) # Validate setpoint range if request.setpoint_hz < 0 or request.setpoint_hz > 50: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Setpoint must be between 0 and 50 Hz" ) # Check if pump is in emergency stop if self.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id): raise HTTPException( status_code=status.HTTP_423_LOCKED, detail="Cannot update setpoint - pump is in emergency stop" ) # Update setpoint success = self.setpoint_manager.update_setpoint( station_id=station_id, pump_id=pump_id, setpoint_hz=request.setpoint_hz, user_id=token_data.username, reason=request.reason or "Manual update via REST API" ) if success: return { "status": "setpoint_updated", "station_id": station_id, "pump_id": pump_id, "setpoint_hz": request.setpoint_hz, "updated_by": token_data.username, "reason": request.reason, "timestamp": datetime.now().isoformat() } else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update setpoint" ) except HTTPException: raise except Exception as e: logger.error( "failed_to_update_pump_setpoint", station_id=station_id, pump_id=pump_id, error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update setpoint for {station_id}/{pump_id}" ) @self.app.post( "/api/v1/emergency-stop", summary="Trigger Emergency Stop", tags=["Emergency Stop"], status_code=status.HTTP_201_CREATED ) async def trigger_emergency_stop( request: EmergencyStopRequest, token_data: TokenData = Depends(require_permission("emergency_stop")), security_manager: SecurityManager = Depends(get_security_manager) ): """ Trigger emergency stop ("big red button"). Scope: - If station_id and pump_id provided: Stop single pump - If station_id only: Stop all pumps at station - If neither: Stop ALL pumps system-wide Requires permission: emergency_stop """ try: # Check station access if station_id is provided if request.station_id and not security_manager.can_access_station(token_data, request.station_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Access denied to station {request.station_id}" ) # Use authenticated user as triggered_by triggered_by = token_data.username if request.station_id and request.pump_id: # Single pump stop result = self.emergency_stop_manager.emergency_stop_pump( station_id=request.station_id, pump_id=request.pump_id, reason=request.reason, user_id=triggered_by ) scope = f"pump {request.station_id}/{request.pump_id}" elif request.station_id: # Station-wide stop result = self.emergency_stop_manager.emergency_stop_station( station_id=request.station_id, reason=request.reason, user_id=triggered_by ) scope = f"station {request.station_id}" else: # System-wide stop result = self.emergency_stop_manager.emergency_stop_system( reason=request.reason, user_id=triggered_by ) scope = "system-wide" if result: return { "status": "emergency_stop_triggered", "scope": scope, "reason": request.reason, "triggered_by": triggered_by, "timestamp": datetime.now().isoformat() } else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to trigger emergency stop" ) except HTTPException: raise except Exception as e: logger.error("failed_to_trigger_emergency_stop", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to trigger emergency stop" ) @self.app.post( "/api/v1/emergency-stop/clear", summary="Clear Emergency Stop", tags=["Emergency Stop"] ) async def clear_emergency_stop( request: EmergencyStopClearRequest, token_data: TokenData = Depends(require_permission("clear_emergency_stop")) ): """ Clear all active emergency stops. Requires permission: clear_emergency_stop """ try: # Use authenticated user as cleared_by cleared_by = token_data.username # Clear system-wide emergency stop self.emergency_stop_manager.clear_emergency_stop_system( reason=request.notes, user_id=cleared_by ) return { "status": "emergency_stop_cleared", "cleared_by": cleared_by, "notes": request.notes, "timestamp": datetime.now().isoformat() } except Exception as e: logger.error("failed_to_clear_emergency_stop", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to clear emergency stop" ) @self.app.get( "/api/v1/emergency-stop/status", summary="Get Emergency Stop Status", tags=["Emergency Stop"] ) async def get_emergency_stop_status( token_data: TokenData = Depends(require_permission("read_safety_status")) ): """ Check if any emergency stops are active. Requires permission: read_safety_status """ try: # Check system-wide emergency stop system_stop = self.emergency_stop_manager.system_emergency_stop # Count station and pump stops station_stops = len(self.emergency_stop_manager.emergency_stop_stations) pump_stops = len(self.emergency_stop_manager.emergency_stop_pumps) return { "system_emergency_stop": system_stop, "station_emergency_stops": station_stops, "pump_emergency_stops": pump_stops, "any_active": system_stop or station_stops > 0 or pump_stops > 0, "timestamp": datetime.now().isoformat() } except Exception as e: logger.error("failed_to_get_emergency_stop_status", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve emergency stop status" ) @self.app.get( "/api/v1/security/status", summary="Get Security Status", tags=["Security"] ) async def get_security_status( token_data: TokenData = Depends(require_permission("read_security_status")) ): """ Get current security status and configuration. Requires permission: read_security_status """ try: security_manager = get_security_manager() tls_manager = get_tls_manager() # Get security status auth_status = security_manager.get_security_status() tls_status = tls_manager.get_tls_status() return { "authentication": auth_status, "tls": tls_status, "timestamp": datetime.now().isoformat() } except Exception as e: logger.error("failed_to_get_security_status", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve security status" ) async def start(self): """Start the REST API server.""" import uvicorn # Get TLS configuration tls_manager = get_tls_manager() ssl_config = tls_manager.get_rest_api_ssl_config() logger.info( "rest_api_server_starting", host=self.host, port=self.port, tls_enabled=ssl_config is not None ) config_kwargs = { "app": self.app, "host": self.host, "port": self.port, "log_level": "info" } # Add SSL configuration if available if ssl_config: certfile, keyfile = ssl_config config_kwargs.update({ "ssl_certfile": certfile, "ssl_keyfile": keyfile }) config = uvicorn.Config(**config_kwargs) server = uvicorn.Server(config) await server.serve() async def stop(self): """Stop the REST API server.""" logger.info("rest_api_server_stopping")