🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
661 lines
23 KiB
Python
661 lines
23 KiB
Python
"""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)
|
|
)
|
|
)
|