Spaces:
Sleeping
Sleeping
| """ | |
| Device Automation Tool | |
| **✅ REAL IMPLEMENTATION (WebSocket-based)** | |
| This module provides REAL device communication via WebSocket to React Native mobile apps. | |
| No longer using mock implementations - all device functions communicate with actual devices. | |
| Architecture: | |
| - Backend (FastAPI) <--WebSocket--> Mobile App (React Native + Socket.IO) | |
| - Devices connect via WebSocket and register their capabilities | |
| - Server sends commands (camera, location, etc.) and receives real results | |
| - Connection tracking and heartbeat monitoring | |
| Provides device hardware access for AI agents: | |
| - Camera capture (snap/clip) | |
| - Screen recording (start/stop) | |
| - Location services | |
| - System notifications | |
| - Command execution | |
| Governance Integration: | |
| - Camera/Location/Notifications: INTERN+ maturity level | |
| - Screen Recording: SUPERVISED+ maturity level | |
| - Command Execution: AUTONOMOUS only (security critical) | |
| - Full audit trail via device_audit table | |
| - Agent execution tracking for all device sessions | |
| Refactored to use standardized decorators and service factory. | |
| """ | |
| import asyncio | |
| from datetime import datetime | |
| import json | |
| from typing import Any, Dict, List, Optional | |
| import uuid | |
| from sqlalchemy.orm import Session | |
| from core.models import DeviceAudit, DeviceNode, DeviceSession | |
| from core.service_factory import ServiceFactory | |
| from core.structured_logger import get_logger | |
| from core.feature_flags import FeatureFlags | |
| logger = get_logger(__name__) | |
| # Import WebSocket communication | |
| try: | |
| from api.device_websocket import ( | |
| get_connected_devices_info, | |
| is_device_online, | |
| send_device_command, | |
| ) | |
| WEBSOCKET_AVAILABLE = True | |
| logger.info("Device WebSocket module loaded - real device communication enabled") | |
| except ImportError as e: | |
| WEBSOCKET_AVAILABLE = False | |
| logger.warning("Device WebSocket module not available", error=str(e)) | |
| logger.warning("Device functions will fail with connection error") | |
| # Security settings | |
| import os | |
| DEVICE_COMMAND_WHITELIST = os.getenv( | |
| "DEVICE_COMMAND_WHITELIST", | |
| "ls,pwd,cat,grep,head,tail,echo,find,ps,top" | |
| ).split(",") | |
| DEVICE_SHELL_READ_COMMANDS = ["ls", "pwd", "cat", "grep", "head", "tail", "echo", "date", "whoami"] | |
| DEVICE_SHELL_MONITOR_COMMANDS = ["find", "ps", "top", "df", "du", "netstat", "lsof"] | |
| DEVICE_SCREEN_RECORD_MAX_DURATION = int(os.getenv("DEVICE_SCREEN_RECORD_MAX_DURATION", "3600")) # 1 hour default | |
| # ============================================================================ | |
| # Session Manager | |
| # ============================================================================ | |
| class DeviceSessionManager: | |
| """ | |
| Manages active device sessions with automatic cleanup. | |
| Sessions are stored in memory and automatically cleaned up after | |
| a timeout period of inactivity. | |
| """ | |
| def __init__(self, session_timeout_minutes: int = 60): | |
| self.sessions: Dict[str, Dict[str, Any]] = {} | |
| self.session_timeout_minutes = session_timeout_minutes | |
| def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: | |
| """Get an existing session by ID.""" | |
| return self.sessions.get(session_id) | |
| def create_session( | |
| self, | |
| user_id: str, | |
| device_node_id: str, | |
| session_type: str, | |
| agent_id: Optional[str] = None, | |
| configuration: Optional[Dict[str, Any]] = None | |
| ) -> Dict[str, Any]: | |
| """Create a new device session.""" | |
| session_id = str(uuid.uuid4()) | |
| session = { | |
| "session_id": session_id, | |
| "user_id": user_id, | |
| "device_node_id": device_node_id, | |
| "session_type": session_type, | |
| "agent_id": agent_id, | |
| "configuration": configuration or {}, | |
| "status": "active", | |
| "created_at": datetime.now(), | |
| "last_used": datetime.now() | |
| } | |
| self.sessions[session_id] = session | |
| logger.info(f"Device session {session_id} created (type: {session_type})") | |
| return session | |
| def close_session(self, session_id: str) -> bool: | |
| """Close a device session.""" | |
| if session_id in self.sessions: | |
| session = self.sessions[session_id] | |
| session["status"] = "closed" | |
| session["closed_at"] = datetime.now() | |
| del self.sessions[session_id] | |
| logger.info(f"Device session {session_id} closed") | |
| return True | |
| return False | |
| def cleanup_expired_sessions(self): | |
| """Remove expired sessions based on timeout.""" | |
| now = datetime.now() | |
| expired = [] | |
| for session_id, session in self.sessions.items(): | |
| last_used = session.get("last_used", session["created_at"]) | |
| age_minutes = (now - last_used).total_seconds() / 60 | |
| if age_minutes > self.session_timeout_minutes: | |
| expired.append(session_id) | |
| for session_id in expired: | |
| self.close_session(session_id) | |
| logger.info(f"Expired device session {session_id} cleaned up") | |
| return len(expired) | |
| # Singleton instance | |
| _device_session_manager: Optional[DeviceSessionManager] = None | |
| def get_device_session_manager() -> DeviceSessionManager: | |
| """Get the global device session manager instance.""" | |
| global _device_session_manager | |
| if _device_session_manager is None: | |
| _device_session_manager = DeviceSessionManager() | |
| return _device_session_manager | |
| # ============================================================================ | |
| # Audit Helper | |
| # ============================================================================ | |
| def _create_device_audit( | |
| db: Session, | |
| user_id: str, | |
| device_node_id: str, | |
| action_type: str, | |
| action_params: Dict[str, Any], | |
| success: bool, | |
| result_summary: Optional[str] = None, | |
| error_message: Optional[str] = None, | |
| result_data: Optional[Dict[str, Any]] = None, | |
| file_path: Optional[str] = None, | |
| duration_ms: Optional[int] = None, | |
| agent_id: Optional[str] = None, | |
| agent_execution_id: Optional[str] = None, | |
| session_id: Optional[str] = None, | |
| governance_check_passed: Optional[bool] = None | |
| ) -> DeviceAudit: | |
| """ | |
| Create an audit entry for a device action. | |
| Args: | |
| db: Database session | |
| user_id: User who triggered the action | |
| device_node_id: Device that performed the action | |
| action_type: Type of action (e.g., 'camera_snap', 'screen_record_start') | |
| action_params: Parameters passed to the action | |
| success: Whether the action succeeded | |
| result_summary: Human-readable summary of the result | |
| error_message: Error message if action failed | |
| result_data: Structured result data | |
| file_path: Path to any file created (screenshots, recordings) | |
| duration_ms: Duration of the action in milliseconds | |
| agent_id: Agent that performed the action | |
| agent_execution_id: Agent execution record | |
| session_id: Device session ID | |
| governance_check_passed: Whether governance check passed | |
| Returns: | |
| DeviceAudit record | |
| """ | |
| audit = DeviceAudit( | |
| id=str(uuid.uuid4()), | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type=action_type, | |
| action_params=action_params, | |
| success=success, | |
| result_summary=result_summary, | |
| error_message=error_message, | |
| result_data=result_data or {}, | |
| file_path=file_path, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| agent_execution_id=agent_execution_id, | |
| session_id=session_id, | |
| governance_check_passed=governance_check_passed, | |
| created_at=datetime.now() | |
| ) | |
| db.add(audit) | |
| db.commit() | |
| logger.info( | |
| f"Device audit created: {action_type} on device {device_node_id} " | |
| f"by user {user_id} - Success: {success}" | |
| ) | |
| return audit | |
| # ============================================================================ | |
| # Governance Helper | |
| # ============================================================================ | |
| async def _check_device_governance( | |
| db: Session, | |
| agent_id: str, | |
| action_type: str, | |
| user_id: str | |
| ) -> Dict[str, Any]: | |
| """ | |
| Check if an agent is allowed to perform a device action. | |
| Args: | |
| db: Database session | |
| agent_id: Agent to check | |
| action_type: Action to check (e.g., 'device_camera_snap') | |
| user_id: User requesting the action | |
| Returns: | |
| Governance check result with 'allowed' boolean | |
| """ | |
| if not FeatureFlags.should_enforce_governance('device'): | |
| return { | |
| "allowed": True, | |
| "reason": "Device governance disabled or emergency bypass active", | |
| "governance_check_passed": True | |
| } | |
| try: | |
| governance = ServiceFactory.get_governance_service(db) | |
| check = governance.can_perform_action(agent_id, action_type) | |
| return { | |
| "allowed": check["allowed"], | |
| "reason": check["reason"], | |
| "governance_check_passed": check["allowed"] | |
| } | |
| except Exception as e: | |
| logger.error(f"Governance check failed for {action_type}: {e}") | |
| # Fail open for availability | |
| return { | |
| "allowed": True, | |
| "reason": f"Governance check failed: {str(e)}", | |
| "governance_check_passed": False | |
| } | |
| # ============================================================================ | |
| # Device Functions | |
| # ============================================================================ | |
| async def device_camera_snap( | |
| db: Session, | |
| user_id: str, | |
| device_node_id: str, | |
| agent_id: Optional[str] = None, | |
| camera_id: Optional[str] = None, | |
| resolution: Optional[str] = "1920x1080", | |
| save_path: Optional[str] = None | |
| ) -> Dict[str, Any]: | |
| """ | |
| Capture an image from the device camera. | |
| Action Complexity: 2 (INTERN+) | |
| Args: | |
| db: Database session | |
| user_id: User requesting the capture | |
| device_node_id: Device to capture from | |
| agent_id: Agent performing the action (for governance) | |
| camera_id: Specific camera to use (if multiple) | |
| resolution: Image resolution (e.g., "1920x1080") | |
| save_path: Where to save the image | |
| Returns: | |
| Dict with success status and file path/base64 data | |
| """ | |
| start_time = datetime.now() | |
| governance_check = None | |
| # Governance check | |
| if agent_id: | |
| governance_check = await _check_device_governance( | |
| db, agent_id, "device_camera_snap", user_id | |
| ) | |
| if not governance_check["allowed"]: | |
| return { | |
| "success": False, | |
| "error": governance_check["reason"], | |
| "governance_blocked": True | |
| } | |
| try: | |
| # Check WebSocket availability | |
| if not WEBSOCKET_AVAILABLE: | |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") | |
| # Check if device is online | |
| if not is_device_online(device_node_id): | |
| raise ValueError( | |
| f"Device {device_node_id} is not currently connected. " | |
| "Please ensure the mobile app is running and connected via WebSocket." | |
| ) | |
| # Send WebSocket command to device | |
| response = await send_device_command( | |
| device_node_id=device_node_id, | |
| command="camera_snap", | |
| params={ | |
| "camera_id": camera_id or "default", | |
| "resolution": resolution, | |
| "save_path": save_path | |
| }, | |
| db=db | |
| ) | |
| if not response.get("success"): | |
| raise ValueError(response.get("error", "Camera capture failed on device")) | |
| result = { | |
| "success": True, | |
| "file_path": response.get("file_path"), | |
| "base64_data": response.get("data", {}).get("base64_data"), | |
| "resolution": resolution, | |
| "camera_id": camera_id or "default", | |
| "captured_at": datetime.now().isoformat() | |
| } | |
| # Create audit entry | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="camera_snap", | |
| action_params={ | |
| "camera_id": camera_id, | |
| "resolution": resolution, | |
| "save_path": save_path | |
| }, | |
| success=True, | |
| result_summary=f"Camera capture successful: {result['file_path']}", | |
| result_data=result, | |
| file_path=result["file_path"], | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.info(f"Camera snap successful for device {device_node_id}") | |
| return result | |
| except Exception as e: | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| error_msg = f"Camera snap failed: {str(e)}" | |
| # Create audit entry for failure | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="camera_snap", | |
| action_params={ | |
| "camera_id": camera_id, | |
| "resolution": resolution, | |
| "save_path": save_path | |
| }, | |
| success=False, | |
| error_message=error_msg, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.error(error_msg) | |
| return { | |
| "success": False, | |
| "error": error_msg | |
| } | |
| async def device_screen_record_start( | |
| db: Session, | |
| user_id: str, | |
| device_node_id: str, | |
| agent_id: Optional[str] = None, | |
| duration_seconds: Optional[int] = None, | |
| audio_enabled: bool = False, | |
| resolution: Optional[str] = "1920x1080", | |
| output_format: str = "mp4" | |
| ) -> Dict[str, Any]: | |
| """ | |
| Start a screen recording session. | |
| Action Complexity: 3 (SUPERVISED+) | |
| Args: | |
| db: Database session | |
| user_id: User requesting the recording | |
| device_node_id: Device to record | |
| agent_id: Agent performing the action (for governance) | |
| duration_seconds: Maximum duration (default: from env, 3600s) | |
| audio_enabled: Whether to capture audio | |
| resolution: Recording resolution | |
| output_format: Output format (mp4, webm, gif) | |
| Returns: | |
| Dict with session_id and recording details | |
| """ | |
| start_time = datetime.now() | |
| governance_check = None | |
| # Governance check | |
| if agent_id: | |
| governance_check = await _check_device_governance( | |
| db, agent_id, "device_screen_record_start", user_id | |
| ) | |
| if not governance_check["allowed"]: | |
| return { | |
| "success": False, | |
| "error": governance_check["reason"], | |
| "governance_blocked": True | |
| } | |
| try: | |
| # Get device node | |
| device = db.query(DeviceNode).filter( | |
| DeviceNode.device_id == device_node_id | |
| ).first() | |
| if not device: | |
| raise ValueError(f"Device {device_node_id} not found") | |
| # Validate duration | |
| max_duration = DEVICE_SCREEN_RECORD_MAX_DURATION | |
| if duration_seconds and duration_seconds > max_duration: | |
| raise ValueError( | |
| f"Duration {duration_seconds}s exceeds maximum {max_duration}s" | |
| ) | |
| # Create session | |
| session_manager = get_device_session_manager() | |
| session = session_manager.create_session( | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| session_type="screen_record", | |
| agent_id=agent_id, | |
| configuration={ | |
| "duration_seconds": duration_seconds or max_duration, | |
| "audio_enabled": audio_enabled, | |
| "resolution": resolution, | |
| "output_format": output_format | |
| } | |
| ) | |
| # Create database session record | |
| db_session = DeviceSession( | |
| id=str(uuid.uuid4()), | |
| session_id=session["session_id"], | |
| device_node_id=device_node_id, | |
| user_id=user_id, | |
| agent_id=agent_id, | |
| session_type="screen_record", | |
| status="active", | |
| configuration=session["configuration"], | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| db.add(db_session) | |
| db.commit() | |
| # Send WebSocket command to device to start recording | |
| if WEBSOCKET_AVAILABLE: | |
| try: | |
| response = await send_device_command( | |
| device_node_id=device_node_id, | |
| command="screen_record_start", | |
| params={ | |
| "session_id": session["session_id"], | |
| "duration_seconds": duration_seconds or max_duration, | |
| "audio_enabled": audio_enabled, | |
| "resolution": resolution, | |
| "output_format": output_format | |
| }, | |
| db=db | |
| ) | |
| if not response.get("success"): | |
| # Update session status to failed | |
| db_session.status = "failed" | |
| db.commit() | |
| raise ValueError(response.get("error", "Screen recording start failed on device")) | |
| except ValueError as e: | |
| # Update session status to failed | |
| db_session.status = "failed" | |
| db.commit() | |
| raise | |
| result = { | |
| "success": True, | |
| "session_id": session["session_id"], | |
| "device_node_id": device_node_id, | |
| "configuration": session["configuration"], | |
| "started_at": session["created_at"].isoformat() | |
| } | |
| # Create audit entry | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="screen_record_start", | |
| action_params=session["configuration"], | |
| success=True, | |
| result_summary=f"Screen recording started: {session['session_id']}", | |
| result_data=result, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| session_id=session["session_id"], | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.info(f"Screen recording started for device {device_node_id}: {session['session_id']}") | |
| return result | |
| except Exception as e: | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| error_msg = f"Screen record start failed: {str(e)}" | |
| # Create audit entry for failure | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="screen_record_start", | |
| action_params={ | |
| "duration_seconds": duration_seconds, | |
| "audio_enabled": audio_enabled, | |
| "resolution": resolution, | |
| "output_format": output_format | |
| }, | |
| success=False, | |
| error_message=error_msg, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.error(error_msg) | |
| return { | |
| "success": False, | |
| "error": error_msg | |
| } | |
| async def device_screen_record_stop( | |
| db: Session, | |
| user_id: str, | |
| session_id: str | |
| ) -> Dict[str, Any]: | |
| """ | |
| Stop a screen recording session. | |
| Action Complexity: 3 (SUPERVISED+) | |
| Args: | |
| db: Database session | |
| user_id: User requesting to stop | |
| session_id: Recording session to stop | |
| Returns: | |
| Dict with file path and recording details | |
| """ | |
| start_time = datetime.now() | |
| try: | |
| # Get session | |
| session_manager = get_device_session_manager() | |
| session = session_manager.get_session(session_id) | |
| if not session: | |
| raise ValueError(f"Session {session_id} not found") | |
| if session["user_id"] != user_id: | |
| raise ValueError(f"Session {session_id} does not belong to user {user_id}") | |
| # Send WebSocket command to device to stop recording | |
| file_path = None | |
| duration_seconds = 0 | |
| if WEBSOCKET_AVAILABLE: | |
| try: | |
| response = await send_device_command( | |
| device_node_id=session["device_node_id"], | |
| command="screen_record_stop", | |
| params={ | |
| "session_id": session_id | |
| }, | |
| db=db | |
| ) | |
| if response.get("success"): | |
| file_path = response.get("file_path") | |
| duration_seconds = response.get("data", {}).get("duration_seconds", 0) | |
| else: | |
| raise ValueError(response.get("error", "Screen recording stop failed on device")) | |
| except ValueError as e: | |
| logger.error(f"Failed to stop screen recording: {e}") | |
| # Continue to close session even if stop command fails | |
| # Close session | |
| session_manager.close_session(session_id) | |
| # Update database session record | |
| db_session = db.query(DeviceSession).filter( | |
| DeviceSession.session_id == session_id | |
| ).first() | |
| if db_session: | |
| db_session.status = "closed" | |
| db_session.closed_at = datetime.now() | |
| db.commit() | |
| result = { | |
| "success": True, | |
| "session_id": session_id, | |
| "file_path": file_path or f"/tmp/recording_{session_id}.mp4", | |
| "duration_seconds": duration_seconds or (datetime.now() - session["created_at"]).total_seconds(), | |
| "stopped_at": datetime.now().isoformat() | |
| } | |
| # Create audit entry | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=session["device_node_id"], | |
| action_type="screen_record_stop", | |
| action_params={"session_id": session_id}, | |
| success=True, | |
| result_summary=f"Screen recording stopped: {result['file_path']}", | |
| result_data=result, | |
| file_path=result["file_path"], | |
| duration_ms=duration_ms, | |
| agent_id=session.get("agent_id"), | |
| session_id=session_id | |
| ) | |
| logger.info(f"Screen recording stopped: {session_id}") | |
| return result | |
| except Exception as e: | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| error_msg = f"Screen record stop failed: {str(e)}" | |
| logger.error(error_msg) | |
| return { | |
| "success": False, | |
| "error": error_msg | |
| } | |
| async def device_get_location( | |
| db: Session, | |
| user_id: str, | |
| device_node_id: str, | |
| agent_id: Optional[str] = None, | |
| accuracy: str = "high" | |
| ) -> Dict[str, Any]: | |
| """ | |
| Get the device's current location. | |
| Action Complexity: 2 (INTERN+) | |
| Args: | |
| db: Database session | |
| user_id: User requesting location | |
| device_node_id: Device to locate | |
| agent_id: Agent performing the action (for governance) | |
| accuracy: Location accuracy (high, medium, low) | |
| Returns: | |
| Dict with latitude, longitude, and accuracy | |
| """ | |
| start_time = datetime.now() | |
| governance_check = None | |
| # Governance check | |
| if agent_id: | |
| governance_check = await _check_device_governance( | |
| db, agent_id, "device_get_location", user_id | |
| ) | |
| if not governance_check["allowed"]: | |
| return { | |
| "success": False, | |
| "error": governance_check["reason"], | |
| "governance_blocked": True | |
| } | |
| try: | |
| # Check WebSocket availability | |
| if not WEBSOCKET_AVAILABLE: | |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") | |
| # Check if device is online | |
| if not is_device_online(device_node_id): | |
| raise ValueError( | |
| f"Device {device_node_id} is not currently connected. " | |
| "Please ensure the mobile app is running and connected via WebSocket." | |
| ) | |
| # Send WebSocket command to device | |
| response = await send_device_command( | |
| device_node_id=device_node_id, | |
| command="get_location", | |
| params={ | |
| "accuracy": accuracy | |
| }, | |
| db=db | |
| ) | |
| if not response.get("success"): | |
| raise ValueError(response.get("error", "Get location failed on device")) | |
| location_data = response.get("data", {}) | |
| result = { | |
| "success": True, | |
| "latitude": location_data.get("latitude"), | |
| "longitude": location_data.get("longitude"), | |
| "accuracy": accuracy, | |
| "altitude": location_data.get("altitude"), | |
| "timestamp": location_data.get("timestamp", datetime.now().isoformat()) | |
| } | |
| # Create audit entry | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="get_location", | |
| action_params={"accuracy": accuracy}, | |
| success=True, | |
| result_summary=f"Location retrieved: {result['latitude']}, {result['longitude']}", | |
| result_data=result, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.info(f"Location retrieved for device {device_node_id}") | |
| return result | |
| except Exception as e: | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| error_msg = f"Get location failed: {str(e)}" | |
| # Create audit entry for failure | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="get_location", | |
| action_params={"accuracy": accuracy}, | |
| success=False, | |
| error_message=error_msg, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.error(error_msg) | |
| return { | |
| "success": False, | |
| "error": error_msg | |
| } | |
| async def device_send_notification( | |
| db: Session, | |
| user_id: str, | |
| device_node_id: str, | |
| title: str, | |
| body: str, | |
| agent_id: Optional[str] = None, | |
| icon: Optional[str] = None, | |
| sound: Optional[str] = None | |
| ) -> Dict[str, Any]: | |
| """ | |
| Send a system notification to the device. | |
| Action Complexity: 2 (INTERN+) | |
| Args: | |
| db: Database session | |
| user_id: User sending notification | |
| device_node_id: Device to notify | |
| title: Notification title | |
| body: Notification body | |
| agent_id: Agent performing the action (for governance) | |
| icon: Optional icon path | |
| sound: Optional sound to play | |
| Returns: | |
| Dict with success status | |
| """ | |
| start_time = datetime.now() | |
| governance_check = None | |
| # Governance check | |
| if agent_id: | |
| governance_check = await _check_device_governance( | |
| db, agent_id, "device_send_notification", user_id | |
| ) | |
| if not governance_check["allowed"]: | |
| return { | |
| "success": False, | |
| "error": governance_check["reason"], | |
| "governance_blocked": True | |
| } | |
| try: | |
| # Check WebSocket availability | |
| if not WEBSOCKET_AVAILABLE: | |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") | |
| # Check if device is online | |
| if not is_device_online(device_node_id): | |
| raise ValueError( | |
| f"Device {device_node_id} is not currently connected. " | |
| "Please ensure the mobile app is running and connected via WebSocket." | |
| ) | |
| # Send WebSocket command to device | |
| response = await send_device_command( | |
| device_node_id=device_node_id, | |
| command="send_notification", | |
| params={ | |
| "title": title, | |
| "body": body, | |
| "icon": icon, | |
| "sound": sound | |
| }, | |
| db=db | |
| ) | |
| if not response.get("success"): | |
| raise ValueError(response.get("error", "Send notification failed on device")) | |
| result = { | |
| "success": True, | |
| "device_node_id": device_node_id, | |
| "title": title, | |
| "body": body, | |
| "sent_at": datetime.now().isoformat() | |
| } | |
| # Create audit entry | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="send_notification", | |
| action_params={ | |
| "title": title, | |
| "body": body, | |
| "icon": icon, | |
| "sound": sound | |
| }, | |
| success=True, | |
| result_summary=f"Notification sent: {title}", | |
| result_data=result, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.info(f"Notification sent to device {device_node_id}: {title}") | |
| return result | |
| except Exception as e: | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| error_msg = f"Send notification failed: {str(e)}" | |
| # Create audit entry for failure | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="send_notification", | |
| action_params={ | |
| "title": title, | |
| "body": body, | |
| "icon": icon, | |
| "sound": sound | |
| }, | |
| success=False, | |
| error_message=error_msg, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.error(error_msg) | |
| return { | |
| "success": False, | |
| "error": error_msg | |
| } | |
| async def device_execute_command( | |
| db: Session, | |
| user_id: str, | |
| device_node_id: str, | |
| command: str, | |
| agent_id: Optional[str] = None, | |
| working_dir: Optional[str] = None, | |
| timeout_seconds: int = 30, | |
| environment: Optional[Dict[str, str]] = None | |
| ) -> Dict[str, Any]: | |
| """ | |
| Execute a shell command on the device. | |
| Action Complexity: 4 (AUTONOMOUS only) | |
| SECURITY CRITICAL: | |
| - AUTONOMOUS agents only | |
| - Command whitelist enforced | |
| - Timeout enforced | |
| - Working directory restricted | |
| - No interactive shells | |
| Args: | |
| db: Database session | |
| user_id: User requesting execution | |
| device_node_id: Device to execute on | |
| command: Command to execute | |
| agent_id: Agent performing the action (for governance) | |
| working_dir: Working directory for command | |
| timeout_seconds: Command timeout (default: 30s) | |
| environment: Environment variables | |
| Returns: | |
| Dict with exit code, stdout, stderr | |
| """ | |
| start_time = datetime.now() | |
| governance_check = None | |
| # Governance check | |
| if agent_id: | |
| # Determine command category for graduated access | |
| command_base = command.split()[0] if command.strip() else "" | |
| governance_action = "device_execute_command" # Default (Complexity 4) | |
| if command_base in DEVICE_SHELL_READ_COMMANDS: | |
| governance_action = "device_shell_read" # Complexity 2 | |
| elif command_base in DEVICE_SHELL_MONITOR_COMMANDS: | |
| governance_action = "device_shell_monitor" # Complexity 3 | |
| governance_check = await _check_device_governance( | |
| db, agent_id, governance_action, user_id | |
| ) | |
| if not governance_check["allowed"]: | |
| return { | |
| "success": False, | |
| "error": governance_check["reason"], | |
| "governance_blocked": True | |
| } | |
| try: | |
| # Get device node | |
| device = db.query(DeviceNode).filter( | |
| DeviceNode.device_id == device_node_id | |
| ).first() | |
| if not device: | |
| raise ValueError(f"Device {device_node_id} not found") | |
| # Security: Validate command against whitelist | |
| command_base = command.split()[0] if command.strip() else "" | |
| if command_base not in DEVICE_COMMAND_WHITELIST: | |
| raise ValueError( | |
| f"Command '{command_base}' not in whitelist. " | |
| f"Allowed: {DEVICE_COMMAND_WHITELIST}" | |
| ) | |
| # Security: Enforce timeout | |
| if timeout_seconds > 300: # 5 minutes max | |
| raise ValueError(f"Timeout {timeout_seconds}s exceeds maximum 300s") | |
| # Check WebSocket availability | |
| if not WEBSOCKET_AVAILABLE: | |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") | |
| # Check if device is online | |
| if not is_device_online(device_node_id): | |
| raise ValueError( | |
| f"Device {device_node_id} is not currently connected. " | |
| "Please ensure the mobile app is running and connected via WebSocket." | |
| ) | |
| # Send WebSocket command to device | |
| response = await send_device_command( | |
| device_node_id=device_node_id, | |
| command="execute_command", | |
| params={ | |
| "command": command, | |
| "working_dir": working_dir, | |
| "timeout_seconds": timeout_seconds, | |
| "environment": environment | |
| }, | |
| db=db | |
| ) | |
| if not response.get("success"): | |
| raise ValueError(response.get("error", "Command execution failed on device")) | |
| command_data = response.get("data", {}) | |
| result = { | |
| "success": True, | |
| "exit_code": command_data.get("exit_code", 0), | |
| "stdout": command_data.get("stdout", ""), | |
| "stderr": command_data.get("stderr", ""), | |
| "command": command, | |
| "working_dir": working_dir, | |
| "timeout_seconds": timeout_seconds, | |
| "executed_at": datetime.now().isoformat() | |
| } | |
| # Create audit entry | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="execute_command", | |
| action_params={ | |
| "command": command, | |
| "working_dir": working_dir, | |
| "timeout_seconds": timeout_seconds, | |
| "environment": environment | |
| }, | |
| success=True, | |
| result_summary=f"Command executed: {command} (exit: {result['exit_code']})", | |
| result_data=result, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.info(f"Command executed on device {device_node_id}: {command}") | |
| return result | |
| except Exception as e: | |
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) | |
| error_msg = f"Execute command failed: {str(e)}" | |
| # Create audit entry for failure | |
| _create_device_audit( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_node_id, | |
| action_type="execute_command", | |
| action_params={ | |
| "command": command, | |
| "working_dir": working_dir, | |
| "timeout_seconds": timeout_seconds, | |
| "environment": environment | |
| }, | |
| success=False, | |
| error_message=error_msg, | |
| duration_ms=duration_ms, | |
| agent_id=agent_id, | |
| governance_check_passed=governance_check["governance_check_passed"] if governance_check else None | |
| ) | |
| logger.error(error_msg) | |
| return { | |
| "success": False, | |
| "error": error_msg | |
| } | |
| # ============================================================================ | |
| # Helper Functions | |
| # ============================================================================ | |
| async def get_device_info( | |
| db: Session, | |
| device_node_id: str | |
| ) -> Optional[Dict[str, Any]]: | |
| """ | |
| Get information about a device. | |
| Args: | |
| db: Database session | |
| device_node_id: Device ID | |
| Returns: | |
| Device information or None | |
| """ | |
| device = db.query(DeviceNode).filter( | |
| DeviceNode.device_id == device_node_id | |
| ).first() | |
| if not device: | |
| return None | |
| return { | |
| "id": device.id, | |
| "device_id": device.device_id, | |
| "name": device.name, | |
| "node_type": device.node_type, | |
| "status": device.status, | |
| "platform": device.platform, | |
| "platform_version": device.platform_version, | |
| "architecture": device.architecture, | |
| "capabilities": device.capabilities, | |
| "capabilities_detailed": device.capabilities_detailed, | |
| "hardware_info": device.hardware_info, | |
| "last_seen": device.last_seen.isoformat() if device.last_seen else None | |
| } | |
| async def list_devices( | |
| db: Session, | |
| user_id: str, | |
| status: Optional[str] = None | |
| ) -> List[Dict[str, Any]]: | |
| """ | |
| List devices available to a user. | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| status: Filter by status (online, offline, busy) | |
| Returns: | |
| List of device information | |
| """ | |
| query = db.query(DeviceNode).filter(DeviceNode.user_id == user_id) | |
| if status: | |
| query = query.filter(DeviceNode.status == status) | |
| devices = query.all() | |
| return [ | |
| { | |
| "id": device.id, | |
| "device_id": device.device_id, | |
| "name": device.name, | |
| "node_type": device.node_type, | |
| "status": device.status, | |
| "platform": device.platform, | |
| "capabilities": device.capabilities, | |
| "last_seen": device.last_seen.isoformat() if device.last_seen else None | |
| } | |
| for device in devices | |
| ] | |
| async def execute_device_command( | |
| db: Session, | |
| user_id: str, | |
| agent_id: Optional[str], | |
| device_id: str, | |
| command_type: str, | |
| parameters: Dict[str, Any], | |
| execution_id: Optional[str] = None | |
| ) -> Dict[str, Any]: | |
| """ | |
| Generic wrapper for executing device commands. | |
| Routes to the appropriate specialized device function based on command_type. | |
| This provides a unified interface for the proposal service and other components. | |
| Args: | |
| db: Database session | |
| user_id: User ID requesting the command | |
| agent_id: Optional agent ID for governance | |
| device_id: Device ID to execute command on | |
| command_type: Type of command (camera, location, notification, command) | |
| parameters: Command parameters (varies by command_type) | |
| execution_id: Optional execution ID for tracking | |
| Returns: | |
| Dict with success status and command-specific data | |
| Command Types and Parameters: | |
| - camera: {"timeout": int} | |
| - location: {"high_accuracy": bool} | |
| - notification: {"title": str, "body": str} | |
| - command: {"command": str, "working_dir": str, "timeout": int} | |
| """ | |
| try: | |
| if command_type == "camera": | |
| # Camera capture | |
| timeout = parameters.get("timeout", 10) | |
| return await device_camera_snap( | |
| db=db, | |
| user_id=user_id, | |
| agent_id=agent_id, | |
| timeout_seconds=timeout | |
| ) | |
| elif command_type == "location": | |
| # Get location | |
| high_accuracy = parameters.get("high_accuracy", True) | |
| return await device_get_location( | |
| db=db, | |
| user_id=user_id, | |
| agent_id=agent_id, | |
| high_accuracy=high_accuracy | |
| ) | |
| elif command_type == "notification": | |
| # Send notification | |
| title = parameters.get("title", "Notification") | |
| body = parameters.get("body", "") | |
| return await device_send_notification( | |
| db=db, | |
| user_id=user_id, | |
| agent_id=agent_id, | |
| title=title, | |
| body=body | |
| ) | |
| elif command_type == "command": | |
| # Execute shell command | |
| command = parameters.get("command") | |
| working_dir = parameters.get("working_dir") | |
| timeout = parameters.get("timeout", 30) | |
| return await device_execute_command( | |
| db=db, | |
| user_id=user_id, | |
| device_node_id=device_id, | |
| command=command, | |
| agent_id=agent_id, | |
| working_dir=working_dir, | |
| timeout_seconds=timeout | |
| ) | |
| else: | |
| logger.warning(f"Unknown device command type: {command_type}") | |
| return { | |
| "success": False, | |
| "error": f"Unknown command type: {command_type}. Supported types: camera, location, notification, command" | |
| } | |
| except Exception as e: | |
| logger.error(f"Failed to execute device command: {e}") | |
| return {"success": False, "error": str(e)} | |