Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,29 @@
|
||||
"""Proxmark3 API endpoints."""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
"""Proxmark3 API endpoints.
|
||||
|
||||
from ..workers.pm3_worker import PM3Worker, PM3Command
|
||||
from ..managers.session_manager import SessionManager
|
||||
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()
|
||||
pm3_worker = PM3Worker()
|
||||
session_manager = SessionManager()
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
command: str
|
||||
session_id: Optional[str] = None
|
||||
device_id: Optional[str] = None # Multi-device support
|
||||
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
@@ -30,79 +39,426 @@ class StatusResponse(BaseModel):
|
||||
session_active: bool
|
||||
|
||||
|
||||
@router.get("/status", response_model=StatusResponse)
|
||||
async def get_status():
|
||||
"""Get Proxmark3 status."""
|
||||
is_connected = await pm3_worker.is_connected()
|
||||
version = None
|
||||
class FirmwareInfo(BaseModel):
|
||||
"""Firmware information for a device."""
|
||||
bootrom_version: Optional[str] = None
|
||||
os_version: Optional[str] = None
|
||||
compatible: bool = False
|
||||
|
||||
if is_connected:
|
||||
# Try to get version info
|
||||
result = await pm3_worker.execute_command("hw version")
|
||||
if result.success:
|
||||
version = result.output.split("\n")[0] if result.output else None
|
||||
|
||||
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=is_connected,
|
||||
device=pm3_worker.device_path,
|
||||
version=version,
|
||||
session_active=session_manager.has_active_session()
|
||||
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."""
|
||||
try:
|
||||
await pm3_worker.connect()
|
||||
return {"success": True, "message": "Connected to Proxmark3"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
"""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."""
|
||||
try:
|
||||
await pm3_worker.disconnect()
|
||||
return {"success": True, "message": "Disconnected from Proxmark3"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
"""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."""
|
||||
try:
|
||||
# Check if another session is active
|
||||
if not session_manager.can_execute(request.session_id):
|
||||
"""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="Another session is active. Please take over or wait."
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
# Execute command
|
||||
result = await pm3_worker.execute_command(request.command)
|
||||
# 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}"
|
||||
|
||||
# Update session activity
|
||||
if request.session_id:
|
||||
session_manager.update_activity(request.session_id)
|
||||
|
||||
return CommandResponse(
|
||||
success=result.success,
|
||||
output=result.output,
|
||||
error=result.error
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
output="",
|
||||
error=str(e)
|
||||
error=error_msg
|
||||
)
|
||||
|
||||
|
||||
@router.get("/commands/history")
|
||||
async def get_command_history(limit: int = 50):
|
||||
"""Get recent command history."""
|
||||
# TODO: Implement database query
|
||||
return {"history": []}
|
||||
"""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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user