| """ |
| 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__) |
|
|
| |
| 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") |
|
|
| |
| 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")) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| _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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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}") |
| |
| return { |
| "allowed": True, |
| "reason": f"Governance check failed: {str(e)}", |
| "governance_check_passed": False |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| 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: |
| |
| if not WEBSOCKET_AVAILABLE: |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") |
|
|
| |
| 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." |
| ) |
|
|
| |
| 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() |
| } |
|
|
| |
| 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_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 |
|
|
| |
| 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: |
| |
| device = db.query(DeviceNode).filter( |
| DeviceNode.device_id == device_node_id |
| ).first() |
|
|
| if not device: |
| raise ValueError(f"Device {device_node_id} not found") |
|
|
| |
| 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" |
| ) |
|
|
| |
| 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 |
| } |
| ) |
|
|
| |
| 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() |
|
|
| |
| 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"): |
| |
| db_session.status = "failed" |
| db.commit() |
| raise ValueError(response.get("error", "Screen recording start failed on device")) |
|
|
| except ValueError as e: |
| |
| 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() |
| } |
|
|
| |
| 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_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: |
| |
| 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}") |
|
|
| |
| 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}") |
| |
|
|
| |
| session_manager.close_session(session_id) |
|
|
| |
| 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() |
| } |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| if not WEBSOCKET_AVAILABLE: |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") |
|
|
| |
| 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." |
| ) |
|
|
| |
| 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()) |
| } |
|
|
| |
| 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_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 |
|
|
| |
| 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: |
| |
| if not WEBSOCKET_AVAILABLE: |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") |
|
|
| |
| 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." |
| ) |
|
|
| |
| 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() |
| } |
|
|
| |
| 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_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 |
|
|
| |
| if agent_id: |
| |
| command_base = command.split()[0] if command.strip() else "" |
| |
| governance_action = "device_execute_command" |
| if command_base in DEVICE_SHELL_READ_COMMANDS: |
| governance_action = "device_shell_read" |
| elif command_base in DEVICE_SHELL_MONITOR_COMMANDS: |
| governance_action = "device_shell_monitor" |
|
|
| 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: |
| |
| device = db.query(DeviceNode).filter( |
| DeviceNode.device_id == device_node_id |
| ).first() |
|
|
| if not device: |
| raise ValueError(f"Device {device_node_id} not found") |
|
|
| |
| 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}" |
| ) |
|
|
| |
| if timeout_seconds > 300: |
| raise ValueError(f"Timeout {timeout_seconds}s exceeds maximum 300s") |
|
|
| |
| if not WEBSOCKET_AVAILABLE: |
| raise ValueError("Device WebSocket module not available. Cannot communicate with devices.") |
|
|
| |
| 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." |
| ) |
|
|
| |
| 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() |
| } |
|
|
| |
| 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_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 |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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": |
| |
| 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": |
| |
| 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": |
| |
| 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": |
| |
| 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)} |
|
|