"""PM3 Service Layer. This service encapsulates all PM3-related business logic and is used by both REST API and BLE GATT handlers to avoid code duplication. """ from typing import Optional, Dict, Any, List from dataclasses import dataclass from ..workers.pm3_worker import PM3Worker, PM3CommandResult from ..managers.session_manager import SessionManager from ..managers.pm3_device_manager import PM3DeviceManager, PM3Device, DeviceStatus @dataclass class PM3ServiceError: """Error response from PM3 service.""" code: str # "session_locked", "pm3_not_connected", "command_failed" message: str details: Optional[str] = None @dataclass class PM3ServiceResult: """Result from PM3 service operations.""" success: bool data: Optional[Dict[str, Any]] = None error: Optional[PM3ServiceError] = None class PM3Service: """PM3 service layer for command execution and status queries. This service can be used by multiple interfaces: - REST API (FastAPI endpoints) - BLE GATT (Bluetooth handlers) - Plugin system (future) It encapsulates: - Session validation - PM3 command execution - Session management - Response formatting """ def __init__( self, pm3_worker: Optional[PM3Worker] = None, session_manager: Optional[SessionManager] = None, device_manager: Optional[PM3DeviceManager] = None ): """Initialize PM3 service. Args: pm3_worker: PM3 worker instance (legacy, kept for backward compatibility) session_manager: Session manager instance (creates new if not provided) device_manager: PM3 device manager for multi-device support (optional) """ self.pm3_worker = pm3_worker or PM3Worker() # Legacy single-device support self.session_manager = session_manager or SessionManager() self.device_manager = device_manager # Multi-device support (optional) async def execute_command( self, command: str, session_id: Optional[str] = None, timeout: Optional[int] = None, device_id: Optional[str] = None ) -> PM3ServiceResult: """Execute a PM3 command with session validation. This method handles: 1. Session validation 2. Command execution 3. Session activity updates 4. Error handling Args: command: PM3 command to execute session_id: Optional session ID for multi-user support timeout: Optional command timeout in seconds device_id: Optional device ID for multi-device support (uses legacy worker if not provided) Returns: PM3ServiceResult with success status and data/error """ # Determine which worker to use worker = None device_path = None if device_id and self.device_manager: # Multi-device mode: get device from device manager device = await self.device_manager.get_device(device_id) if not device: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_not_found", message=f"Device {device_id} not found", details="Device may have been disconnected" ) ) if not device.worker: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_no_worker", message=f"Device {device_id} has no worker instance", details="Device may not be fully initialized" ) ) worker = device.worker device_path = device.device_path else: # Legacy single-device mode worker = self.pm3_worker device_path = self.pm3_worker.device_path # 1. Validate session (per-device) if not self.session_manager.can_execute(session_id, device_id): return PM3ServiceResult( success=False, error=PM3ServiceError( code="session_locked", message=f"Another session is active for device {device_id or 'default'}", details="Please take over the session or wait for it to expire" ) ) # 2. Check PM3 connection if not await worker.is_connected(): return PM3ServiceResult( success=False, error=PM3ServiceError( code="pm3_not_connected", message="Proxmark3 not connected", details=f"Device path: {device_path}" ) ) # 3. Execute command try: result = await worker.execute_command(command) # 4. Update session activity if session_id: self.session_manager.update_activity(session_id, device_id) # 5. Return result if result.success: return PM3ServiceResult( success=True, data={ "output": result.output, "command": command, "session_id": session_id, "device_id": device_id } ) else: # Include both error message and output for better diagnostics # PM3 CLI often outputs error details to stdout error_details = result.error or "" if result.output: error_details = f"{error_details}\nOutput: {result.output}" if error_details else result.output return PM3ServiceResult( success=False, error=PM3ServiceError( code="command_failed", message="PM3 command failed", details=error_details ) ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="execution_error", message="Command execution error", details=str(e) ) ) async def get_status(self, device_id: Optional[str] = None) -> PM3ServiceResult: """Get PM3 device status. Args: device_id: Optional device ID. If None and device_manager exists, returns all devices. If None and no device_manager, returns legacy single device status. Returns: PM3ServiceResult with connection status, version, etc. """ try: # Multi-device mode: return all devices or specific device if device_id is None and self.device_manager: # Return status for all devices devices = await self.device_manager.get_all_devices() return PM3ServiceResult( success=True, data={ "devices": [ { "device_id": d.device_id, "device_path": d.device_path, "friendly_name": d.friendly_name or d.device_path.split('/')[-1], "serial_number": d.serial_number, "connected": d.status == DeviceStatus.CONNECTED, "in_use": d.status == DeviceStatus.IN_USE, "status": d.status.value, "firmware_info": { "bootrom_version": d.firmware_info.bootrom_version, "os_version": d.firmware_info.os_version, "compatible": d.firmware_info.compatible, }, "last_seen": d.last_seen.isoformat(), } for d in devices ] } ) elif device_id and self.device_manager: # Return status for specific device device = await self.device_manager.get_device(device_id) if not device: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_not_found", message=f"Device {device_id} not found" ) ) return PM3ServiceResult( success=True, data={ "device_id": device.device_id, "device_path": device.device_path, "friendly_name": device.friendly_name or device.device_path.split('/')[-1], "serial_number": device.serial_number, "connected": device.status == DeviceStatus.CONNECTED, "in_use": device.status == DeviceStatus.IN_USE, "status": device.status.value, "firmware_info": { "bootrom_version": device.firmware_info.bootrom_version, "os_version": device.firmware_info.os_version, "compatible": device.firmware_info.compatible, }, "last_seen": device.last_seen.isoformat(), } ) else: # Legacy single-device mode is_connected = await self.pm3_worker.is_connected() version = None if is_connected: # Try to get version info result = await self.pm3_worker.execute_command("hw version") if result.success: version = result.output.split("\n")[0] if result.output else None return PM3ServiceResult( success=True, data={ "connected": is_connected, "device_path": self.pm3_worker.device_path, "version": version, "session_active": self.session_manager.has_active_session() } ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="status_error", message="Failed to get PM3 status", details=str(e) ) ) async def connect(self) -> PM3ServiceResult: """Connect to PM3 device. Returns: PM3ServiceResult indicating success/failure """ try: await self.pm3_worker.connect() return PM3ServiceResult( success=True, data={"message": "Connected to Proxmark3"} ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="connection_error", message="Failed to connect to PM3", details=str(e) ) ) async def disconnect(self) -> PM3ServiceResult: """Disconnect from PM3 device. Returns: PM3ServiceResult indicating success/failure """ try: await self.pm3_worker.disconnect() return PM3ServiceResult( success=True, data={"message": "Disconnected from Proxmark3"} ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="disconnection_error", message="Failed to disconnect from PM3", details=str(e) ) ) # Session management methods (for both REST and BLE) async def create_session( self, client_ip: str = "unknown", user_agent: Optional[str] = None, force_takeover: bool = False, device_id: Optional[str] = None ) -> PM3ServiceResult: """Create a new session for a device. Args: client_ip: Client IP address user_agent: Client user agent string force_takeover: Whether to forcefully take over existing session device_id: Device ID to create session for (None for legacy mode) Returns: PM3ServiceResult with session_id """ success, session_id, error_msg = await self.session_manager.create_session( client_ip=client_ip, user_agent=user_agent, force_takeover=force_takeover, device_id=device_id ) if success and session_id: return PM3ServiceResult( success=True, data={"session_id": session_id, "device_id": device_id} ) else: return PM3ServiceResult( success=False, error=PM3ServiceError( code="session_exists", message=error_msg or "Session already exists", details="Set force_takeover=true to take over" ) ) async def release_session( self, session_id: str, device_id: Optional[str] = None ) -> PM3ServiceResult: """Release a session. Args: session_id: Session ID to release device_id: Device ID (if known) Returns: PM3ServiceResult indicating success """ released = await self.session_manager.release_session(session_id, device_id) if released: return PM3ServiceResult( success=True, data={"message": "Session released"} ) else: return PM3ServiceResult( success=False, error=PM3ServiceError( code="session_not_found", message="Session not found or already released" ) ) def get_session_info(self, device_id: Optional[str] = None) -> PM3ServiceResult: """Get session information for a device. Args: device_id: Device ID to get session for (None for any active session) Returns: PM3ServiceResult with session details """ session = self.session_manager.get_active_session(device_id) if session: return PM3ServiceResult( success=True, data={ "session_id": session.session_id, "device_id": session.device_id, "client_ip": session.client_ip, "created_at": session.created_at, "last_activity": session.last_activity, } ) else: return PM3ServiceResult( success=False, error=PM3ServiceError( code="session_not_found", message=f"No active session for device {device_id or 'default'}" ) ) def get_all_sessions(self) -> PM3ServiceResult: """Get all active sessions. Returns: PM3ServiceResult with all session details """ sessions = self.session_manager.get_all_active_sessions() return PM3ServiceResult( success=True, data={ "sessions": [ { "session_id": s.session_id, "device_id": s.device_id, "client_ip": s.client_ip, "created_at": s.created_at, "last_activity": s.last_activity, } for s in sessions.values() ] } ) # Multi-device management methods async def list_devices(self) -> PM3ServiceResult: """List all discovered PM3 devices. Returns: PM3ServiceResult with list of all devices """ if not self.device_manager: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_manager_not_available", message="Device manager not initialized", details="Multi-device support is not enabled" ) ) try: devices = await self.device_manager.get_all_devices() return PM3ServiceResult( success=True, data={ "devices": [device.to_dict() for device in devices] } ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="list_devices_error", message="Failed to list devices", details=str(e) ) ) async def get_available_devices(self) -> PM3ServiceResult: """Get devices without active sessions (available for use). Returns: PM3ServiceResult with list of available devices """ if not self.device_manager: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_manager_not_available", message="Device manager not initialized", details="Multi-device support is not enabled" ) ) try: all_devices = await self.device_manager.get_all_devices() # Filter for devices that are CONNECTED (not IN_USE or other states) available_devices = [ device for device in all_devices if device.status == DeviceStatus.CONNECTED ] return PM3ServiceResult( success=True, data={ "devices": [device.to_dict() for device in available_devices] } ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="get_available_devices_error", message="Failed to get available devices", details=str(e) ) ) async def identify_device( self, device_id: str, duration_ms: int = 2000 ) -> PM3ServiceResult: """Blink LEDs on a device for physical identification. Args: device_id: Device ID to identify duration_ms: Duration to blink LEDs in milliseconds (default: 2000) Returns: PM3ServiceResult indicating success """ if not self.device_manager: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_manager_not_available", message="Device manager not initialized", details="Multi-device support is not enabled" ) ) try: # Check if device exists device = await self.device_manager.get_device(device_id) if not device: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_not_found", message=f"Device {device_id} not found" ) ) # Trigger LED identification await self.device_manager.identify_device(device_id, duration_ms) return PM3ServiceResult( success=True, data={ "message": f"Device {device_id} identified", "device_path": device.device_path, "duration_ms": duration_ms } ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="identify_device_error", message="Failed to identify device", details=str(e) ) ) async def flash_firmware(self, device_id: str) -> PM3ServiceResult: """Flash firmware to a PM3 device. Flashes both bootrom and fullimage from bundled firmware files. Progress is reported via WebSocket notifications. Args: device_id: Device ID to flash Returns: PM3ServiceResult indicating success/failure with message """ if not self.device_manager: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_manager_not_available", message="Device manager not initialized", details="Multi-device support is not enabled" ) ) try: # Check if device exists device = await self.device_manager.get_device(device_id) if not device: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_not_found", message=f"Device {device_id} not found" ) ) # Check if device is already flashing if device.status == DeviceStatus.FLASHING: return PM3ServiceResult( success=False, error=PM3ServiceError( code="device_busy", message="Device is already being flashed", details="Please wait for current flash to complete" ) ) # Flash firmware result = await self.device_manager.flash_firmware(device_id) if result["success"]: return PM3ServiceResult( success=True, data={ "message": result["message"], "device_id": device_id } ) else: return PM3ServiceResult( success=False, error=PM3ServiceError( code="flash_failed", message="Firmware flash failed", details=result.get("error", "Unknown error") ) ) except Exception as e: return PM3ServiceResult( success=False, error=PM3ServiceError( code="flash_error", message="Failed to flash firmware", details=str(e) ) )