"""Proxmark3 API endpoints. Refactored to use PM3Service for all business logic. Endpoints are now thin adapters that convert HTTP requests/responses. Multi-device support: - GET /devices - List all devices - GET /devices/available - List available devices - POST /devices/{device_id}/identify - Blink device LEDs - GET /devices/{device_id}/status - Get device-specific status - GET /status?device_id=xxx - Get status (all devices or specific) - POST /command - Execute command (with optional device_id) """ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from typing import Optional, List, Dict, Any from ..services.container import container router = APIRouter() class CommandRequest(BaseModel): command: str session_id: Optional[str] = None device_id: Optional[str] = None # Multi-device support class CommandResponse(BaseModel): success: bool output: str error: Optional[str] = None class StatusResponse(BaseModel): connected: bool device: str version: Optional[str] = None session_active: bool class FirmwareInfo(BaseModel): """Firmware information for a device.""" bootrom_version: Optional[str] = None os_version: Optional[str] = None compatible: bool = False class DeviceInfo(BaseModel): """Device information response.""" device_id: str device_path: str friendly_name: Optional[str] = None serial_number: Optional[str] = None status: str connected: bool in_use: bool firmware_info: FirmwareInfo usb_vid: Optional[str] = None usb_pid: Optional[str] = None last_seen: str class DeviceListResponse(BaseModel): """Response for device list endpoints.""" devices: List[DeviceInfo] class DeviceStatusResponse(BaseModel): """Response for single device status.""" device_id: str device_path: str friendly_name: Optional[str] = None serial_number: Optional[str] = None connected: bool in_use: bool status: str firmware_info: FirmwareInfo last_seen: str class IdentifyRequest(BaseModel): """Request to identify a device (blink LEDs).""" duration_ms: int = Field(default=2000, ge=500, le=10000, description="LED blink duration in milliseconds") class FlashRequest(BaseModel): """Request to flash firmware to a device.""" confirm: bool = Field(..., description="User confirmation that they want to flash") class FlashResponse(BaseModel): """Response from flash operation.""" success: bool message: str device_id: str def _service_error_to_http_status(error_code: str) -> int: """Map service error codes to HTTP status codes. Args: error_code: Service error code Returns: HTTP status code """ codes = { # Session errors "session_locked": 423, "session_not_found": 404, # Connection errors "pm3_not_connected": 503, "connection_error": 503, "disconnection_error": 500, # Command execution errors "command_failed": 500, "execution_error": 500, "status_error": 500, # Multi-device errors "device_not_found": 404, "device_manager_not_available": 503, "list_devices_error": 500, "get_available_devices_error": 500, "identify_device_error": 500, # Firmware flash errors "device_busy": 409, "flash_failed": 500, "flash_error": 500, } return codes.get(error_code, 500) @router.get("/status") async def get_status(device_id: Optional[str] = Query(None, description="Optional device ID for multi-device support")): """Get Proxmark3 status. Multi-device support: - If device_id is None and device_manager exists: returns all devices - If device_id is provided: returns specific device status - If device_id is None and no device_manager: returns legacy single device status Args: device_id: Optional device ID for multi-device mode Returns: StatusResponse (legacy single device) or dict with devices list (multi-device) """ result = await container.pm3_service.get_status(device_id=device_id) if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message ) # Multi-device mode: return all devices if "devices" in result.data: return {"devices": result.data["devices"]} # Single device mode (specific device or legacy) return StatusResponse( connected=result.data["connected"], device=result.data["device_path"], version=result.data.get("version"), session_active=result.data["session_active"] ) @router.post("/connect") async def connect(): """Connect to Proxmark3 device. Uses PM3Service for business logic. """ result = await container.pm3_service.connect() if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message ) return {"success": True, "message": result.data["message"]} @router.post("/disconnect") async def disconnect(): """Disconnect from Proxmark3 device. Uses PM3Service for business logic. """ result = await container.pm3_service.disconnect() if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message ) return {"success": True, "message": result.data["message"]} @router.post("/command", response_model=CommandResponse) async def execute_command(request: CommandRequest): """Execute a Proxmark3 command. Uses PM3Service for all business logic including: - Session validation - Command execution - Session activity updates - Multi-device support (via device_id in request) Multi-device support: - If device_id is provided: command executes on specified device - If device_id is None: uses legacy single-device mode Args: request: CommandRequest with command, optional session_id, and optional device_id """ result = await container.pm3_service.execute_command( command=request.command, session_id=request.session_id, device_id=request.device_id ) if result.success: return CommandResponse( success=True, output=result.data["output"], error=None ) else: # For session_locked, return 423 via HTTPException # For other errors, return in response body if result.error.code == "session_locked": raise HTTPException( status_code=423, detail=result.error.message ) # Include details in error message for better diagnostics error_msg = result.error.message if result.error.details: error_msg = f"{error_msg}: {result.error.details}" return CommandResponse( success=False, output="", error=error_msg ) @router.get("/commands/history") async def get_command_history(limit: int = 50): """Get recent command history from database.""" import aiosqlite from .. import config try: async with aiosqlite.connect(config.DATABASE_PATH) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT command, response, success, executed_at FROM command_history ORDER BY executed_at DESC LIMIT ?", (limit,) ) rows = await cursor.fetchall() return {"history": [dict(row) for row in rows]} except Exception as e: return {"history": [], "error": str(e)} # ============================================================================ # Multi-Device Endpoints # ============================================================================ @router.get("/devices", response_model=DeviceListResponse) async def list_devices(): """List all discovered PM3 devices. Returns all devices regardless of their status (connected, in use, etc.). Uses PM3DeviceManager via PM3Service. Returns: DeviceListResponse: List of all devices with their status and firmware info """ result = await container.pm3_service.list_devices() if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message ) # Convert device dictionaries to DeviceInfo models devices = [] for device_dict in result.data["devices"]: devices.append(DeviceInfo( device_id=device_dict["device_id"], device_path=device_dict["device_path"], friendly_name=device_dict.get("friendly_name"), serial_number=device_dict.get("serial_number"), status=device_dict["status"], connected=device_dict["status"] in ["CONNECTED", "IN_USE"], in_use=device_dict["status"] == "IN_USE", firmware_info=FirmwareInfo( bootrom_version=device_dict["firmware_info"].get("bootrom_version"), os_version=device_dict["firmware_info"].get("os_version"), compatible=device_dict["firmware_info"].get("compatible", False) ), usb_vid=device_dict.get("usb_vid"), usb_pid=device_dict.get("usb_pid"), last_seen=device_dict["last_seen"] )) return DeviceListResponse(devices=devices) @router.get("/devices/available", response_model=DeviceListResponse) async def list_available_devices(): """List available PM3 devices (not currently in use). Returns only devices that are connected but do not have active sessions. These devices can be selected for new operations. Returns: DeviceListResponse: List of available devices """ result = await container.pm3_service.get_available_devices() if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message ) # Convert device dictionaries to DeviceInfo models devices = [] for device_dict in result.data["devices"]: devices.append(DeviceInfo( device_id=device_dict["device_id"], device_path=device_dict["device_path"], friendly_name=device_dict.get("friendly_name"), serial_number=device_dict.get("serial_number"), status=device_dict["status"], connected=device_dict["status"] in ["CONNECTED", "IN_USE"], in_use=device_dict["status"] == "IN_USE", firmware_info=FirmwareInfo( bootrom_version=device_dict["firmware_info"].get("bootrom_version"), os_version=device_dict["firmware_info"].get("os_version"), compatible=device_dict["firmware_info"].get("compatible", False) ), usb_vid=device_dict.get("usb_vid"), usb_pid=device_dict.get("usb_pid"), last_seen=device_dict["last_seen"] )) return DeviceListResponse(devices=devices) @router.post("/devices/{device_id}/identify") async def identify_device(device_id: str, request: IdentifyRequest = IdentifyRequest()): """Identify a PM3 device by blinking its LEDs. Useful for physically identifying which device corresponds to a device_id when multiple devices are connected. Args: device_id: Device ID to identify request: Request with optional duration_ms (default: 2000ms) Returns: Success message """ result = await container.pm3_service.identify_device( device_id=device_id, duration_ms=request.duration_ms ) if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message ) return {"success": True, "message": result.data["message"]} @router.get("/devices/{device_id}/status", response_model=DeviceStatusResponse) async def get_device_status(device_id: str): """Get status for a specific PM3 device. Args: device_id: Device ID to query Returns: DeviceStatusResponse: Device status and firmware info """ result = await container.pm3_service.get_status(device_id=device_id) if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message ) data = result.data return DeviceStatusResponse( device_id=data["device_id"], device_path=data["device_path"], friendly_name=data.get("friendly_name"), serial_number=data.get("serial_number"), connected=data["connected"], in_use=data["in_use"], status=data["status"], firmware_info=FirmwareInfo( bootrom_version=data["firmware_info"]["bootrom_version"], os_version=data["firmware_info"]["os_version"], compatible=data["firmware_info"]["compatible"] ), last_seen=data["last_seen"] ) @router.post("/devices/{device_id}/flash", response_model=FlashResponse) async def flash_device(device_id: str, request: FlashRequest): """Flash firmware to a PM3 device. Flashes both bootrom and fullimage from bundled firmware files. Progress is reported via WebSocket notifications (pm3_flash_progress event). Args: device_id: Device ID to flash request: FlashRequest with confirmation Returns: FlashResponse with success status and message Raises: HTTPException 400: If confirmation not provided HTTPException 404: If device not found HTTPException 409: If device is already being flashed HTTPException 500: If flash operation fails """ if not request.confirm: raise HTTPException( status_code=400, detail="Confirmation required. Set confirm=true to proceed with flash." ) result = await container.pm3_service.flash_firmware(device_id=device_id) if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), detail=result.error.message if not result.error.details else f"{result.error.message}: {result.error.details}" ) return FlashResponse( success=True, message=result.data["message"], device_id=device_id )