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:
660
app/backend/services/pm3_service.py
Normal file
660
app/backend/services/pm3_service.py
Normal file
@@ -0,0 +1,660 @@
|
||||
"""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)
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user