🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
971 lines
34 KiB
Python
971 lines
34 KiB
Python
"""Proxmark3 Device Manager for multi-device support."""
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
import json
|
|
|
|
try:
|
|
import pyudev
|
|
UDEV_AVAILABLE = True
|
|
except ImportError:
|
|
UDEV_AVAILABLE = False
|
|
|
|
try:
|
|
import serial.tools.list_ports
|
|
SERIAL_AVAILABLE = True
|
|
except ImportError:
|
|
SERIAL_AVAILABLE = False
|
|
|
|
from ..workers.pm3_worker import PM3Worker, SubprocessPM3Worker
|
|
from .. import config
|
|
|
|
# Flag to track if Python bindings work (set once on first try)
|
|
_python_bindings_work = None
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DeviceStatus(Enum):
|
|
"""Device availability status."""
|
|
CONNECTED = "connected" # Ready to use
|
|
DISCONNECTED = "disconnected" # Not detected
|
|
IN_USE = "in_use" # Active session
|
|
ERROR = "error" # Communication error
|
|
VERSION_MISMATCH = "version_mismatch" # Firmware incompatible
|
|
FLASHING = "flashing" # Firmware update in progress
|
|
BOOTLOADER_MODE = "bootloader_mode" # In bootloader, needs flash
|
|
DISABLED = "disabled" # Mismatch ignored by user
|
|
|
|
|
|
@dataclass
|
|
class PM3FirmwareInfo:
|
|
"""Firmware version information."""
|
|
bootrom_version: str = "unknown" # e.g., "v4.14831"
|
|
os_version: str = "unknown" # e.g., "v4.14831"
|
|
client_version: str = "unknown" # Local client version
|
|
compatible: bool = False # Versions match
|
|
needs_upgrade: bool = False # Device firmware older
|
|
needs_downgrade: bool = False # Device firmware newer
|
|
bootloader_outdated: bool = False # Bootloader needs update
|
|
|
|
|
|
@dataclass
|
|
class PM3Device:
|
|
"""Represents a single Proxmark3 device."""
|
|
device_id: str # Unique ID (hash of serial + device path)
|
|
device_path: str # /dev/ttyACM0, /dev/ttyACM1, etc.
|
|
serial_number: Optional[str] = None # USB serial number (if available)
|
|
friendly_name: Optional[str] = None # User-assigned name
|
|
usb_vid: Optional[str] = None # USB Vendor ID
|
|
usb_pid: Optional[str] = None # USB Product ID
|
|
status: DeviceStatus = DeviceStatus.DISCONNECTED
|
|
firmware_info: PM3FirmwareInfo = field(default_factory=PM3FirmwareInfo)
|
|
last_seen: datetime = field(default_factory=datetime.now)
|
|
first_seen: datetime = field(default_factory=datetime.now)
|
|
worker: Optional[PM3Worker] = None # Dedicated worker instance
|
|
metadata: Dict = field(default_factory=dict) # Additional metadata
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dictionary for API responses."""
|
|
return {
|
|
"device_id": self.device_id,
|
|
"device_path": self.device_path,
|
|
"serial_number": self.serial_number,
|
|
"friendly_name": self.friendly_name or self.device_path.split('/')[-1],
|
|
"usb_vid": self.usb_vid,
|
|
"usb_pid": self.usb_pid,
|
|
"status": self.status.value,
|
|
"firmware_info": {
|
|
"bootrom_version": self.firmware_info.bootrom_version,
|
|
"os_version": self.firmware_info.os_version,
|
|
"client_version": self.firmware_info.client_version,
|
|
"compatible": self.firmware_info.compatible,
|
|
"needs_upgrade": self.firmware_info.needs_upgrade,
|
|
"needs_downgrade": self.firmware_info.needs_downgrade,
|
|
},
|
|
"last_seen": self.last_seen.isoformat(),
|
|
"first_seen": self.first_seen.isoformat(),
|
|
"connected": self.status == DeviceStatus.CONNECTED,
|
|
"in_use": self.status == DeviceStatus.IN_USE,
|
|
}
|
|
|
|
|
|
class PM3DeviceManager:
|
|
"""Manages multiple Proxmark3 devices."""
|
|
|
|
# USB VID/PID for Proxmark3 devices
|
|
PM3_USB_VID_PRIMARY = "9ac4" # Standard PM3
|
|
PM3_USB_PID_PRIMARY = "4b8f"
|
|
PM3_USB_VID_EASY = "502d" # PM3 Easy
|
|
PM3_USB_PID_EASY = "502d"
|
|
|
|
# Alternative identifiers
|
|
PM3_VENDOR_IDS = ["9ac4", "502d", "2d0d"] # Known PM3 vendor IDs
|
|
PM3_PRODUCT_IDS = ["4b8f", "502d"] # Known PM3 product IDs
|
|
|
|
def __init__(self):
|
|
"""Initialize device manager."""
|
|
self._devices: Dict[str, PM3Device] = {} # device_id -> PM3Device
|
|
self._lock = asyncio.Lock()
|
|
self._monitor_task: Optional[asyncio.Task] = None
|
|
self._udev_context = None
|
|
self._udev_monitor = None
|
|
self._device_change_callbacks: List = [] # Callbacks for device changes
|
|
|
|
# Device discovery settings
|
|
self.auto_discover = True
|
|
self.discovery_interval = 0.5 # seconds - 500ms for responsive SSE updates
|
|
self.device_timeout = 300 # seconds
|
|
self._last_device_state: Dict[str, str] = {} # device_id -> status for change detection
|
|
|
|
logger.info("PM3DeviceManager initialized")
|
|
|
|
def register_device_change_callback(self, callback):
|
|
"""Register a callback for device list changes.
|
|
|
|
Args:
|
|
callback: Async function that takes a list of PM3Device
|
|
"""
|
|
self._device_change_callbacks.append(callback)
|
|
logger.info(f"Registered device change callback: {callback}")
|
|
|
|
async def _notify_device_change(self, force: bool = False):
|
|
"""Notify all registered callbacks of device list changes.
|
|
|
|
Args:
|
|
force: If True, send notification even if no changes detected
|
|
"""
|
|
# Build current state snapshot
|
|
current_state = {
|
|
d.device_id: d.status.value if hasattr(d.status, 'value') else str(d.status)
|
|
for d in self._devices.values()
|
|
}
|
|
|
|
# Check if anything changed
|
|
if not force and current_state == self._last_device_state:
|
|
return # No changes, skip notification
|
|
|
|
# Update last state
|
|
self._last_device_state = current_state.copy()
|
|
|
|
# Notify all callbacks
|
|
devices = list(self._devices.values())
|
|
for callback in self._device_change_callbacks:
|
|
try:
|
|
await callback(devices)
|
|
except Exception as e:
|
|
logger.error(f"Error in device change callback: {e}")
|
|
|
|
async def start(self):
|
|
"""Start device manager and monitoring."""
|
|
logger.info("Starting PM3 device manager")
|
|
|
|
# Initial device discovery (force notification to send initial state)
|
|
await self.discover_devices()
|
|
await self._notify_device_change(force=True)
|
|
|
|
# Start monitoring for hotplug events
|
|
if UDEV_AVAILABLE:
|
|
await self._start_udev_monitor()
|
|
else:
|
|
logger.warning("pyudev not available, using polling fallback")
|
|
await self._start_polling_monitor()
|
|
|
|
async def stop(self):
|
|
"""Stop device manager and monitoring."""
|
|
logger.info("Stopping PM3 device manager")
|
|
|
|
if self._monitor_task:
|
|
self._monitor_task.cancel()
|
|
try:
|
|
await self._monitor_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Disconnect all devices
|
|
async with self._lock:
|
|
for device in self._devices.values():
|
|
if device.worker:
|
|
await device.worker.disconnect()
|
|
|
|
async def discover_devices(self) -> List[PM3Device]:
|
|
"""Scan USB ports for PM3 devices.
|
|
|
|
Returns:
|
|
List of discovered PM3Device objects
|
|
"""
|
|
async with self._lock:
|
|
discovered_paths = set()
|
|
|
|
# Method 1: Use pyserial to enumerate serial ports
|
|
if SERIAL_AVAILABLE:
|
|
discovered_paths.update(await self._discover_via_serial())
|
|
|
|
# Method 2: Scan /dev/ttyACM* directly
|
|
discovered_paths.update(await self._discover_via_dev())
|
|
|
|
# Process discovered devices
|
|
current_devices = {}
|
|
for device_path in discovered_paths:
|
|
device_id = self._generate_device_id(device_path)
|
|
|
|
if device_id in self._devices:
|
|
# Update existing device
|
|
device = self._devices[device_id]
|
|
device.last_seen = datetime.now()
|
|
device.status = DeviceStatus.CONNECTED
|
|
else:
|
|
# Create new device
|
|
device = await self._create_device(device_path)
|
|
|
|
current_devices[device_id] = device
|
|
|
|
# Mark missing devices as disconnected
|
|
for device_id, device in self._devices.items():
|
|
if device_id not in current_devices:
|
|
device.status = DeviceStatus.DISCONNECTED
|
|
current_devices[device_id] = device
|
|
|
|
self._devices = current_devices
|
|
|
|
connected_count = len([d for d in self._devices.values() if d.status == DeviceStatus.CONNECTED])
|
|
logger.info(f"Discovered {connected_count} connected PM3 devices")
|
|
|
|
# Notify callbacks of device changes (only if state changed)
|
|
await self._notify_device_change()
|
|
|
|
return list(self._devices.values())
|
|
|
|
async def _discover_via_serial(self) -> set:
|
|
"""Discover devices using pyserial."""
|
|
devices = set()
|
|
|
|
try:
|
|
ports = serial.tools.list_ports.comports()
|
|
for port in ports:
|
|
# Check if it's a PM3 device by VID/PID
|
|
if port.vid and port.pid:
|
|
vid = f"{port.vid:04x}"
|
|
pid = f"{port.pid:04x}"
|
|
|
|
if vid in self.PM3_VENDOR_IDS or pid in self.PM3_PRODUCT_IDS:
|
|
devices.add(port.device)
|
|
logger.debug(f"Found PM3 device via serial: {port.device} (VID:{vid} PID:{pid})")
|
|
except Exception as e:
|
|
logger.error(f"Error discovering devices via serial: {e}")
|
|
|
|
return devices
|
|
|
|
async def _discover_via_dev(self) -> set:
|
|
"""Discover devices by scanning /dev/ttyACM*."""
|
|
devices = set()
|
|
|
|
try:
|
|
# Look for /dev/ttyACM* devices
|
|
dev_path = Path("/dev")
|
|
for device_file in dev_path.glob("ttyACM*"):
|
|
if device_file.is_char_device():
|
|
devices.add(str(device_file))
|
|
logger.debug(f"Found device: {device_file}")
|
|
except Exception as e:
|
|
logger.error(f"Error scanning /dev: {e}")
|
|
|
|
return devices
|
|
|
|
async def _create_device(self, device_path: str) -> PM3Device:
|
|
"""Create a new PM3Device object.
|
|
|
|
Args:
|
|
device_path: Path to device (e.g., /dev/ttyACM0)
|
|
|
|
Returns:
|
|
PM3Device object
|
|
"""
|
|
device_id = self._generate_device_id(device_path)
|
|
|
|
# Get USB info if available
|
|
usb_info = await self._get_usb_info(device_path)
|
|
|
|
# Create device object
|
|
device = PM3Device(
|
|
device_id=device_id,
|
|
device_path=device_path,
|
|
serial_number=usb_info.get("serial"),
|
|
usb_vid=usb_info.get("vid"),
|
|
usb_pid=usb_info.get("pid"),
|
|
status=DeviceStatus.CONNECTED,
|
|
friendly_name=None, # Will be set by user or auto-generated
|
|
first_seen=datetime.now(),
|
|
last_seen=datetime.now()
|
|
)
|
|
|
|
# Use SubprocessPM3Worker (more reliable than SWIG bindings which may not be built)
|
|
device.worker = SubprocessPM3Worker(device_path=device_path)
|
|
|
|
# Query firmware version (in background, don't block)
|
|
asyncio.create_task(self._query_firmware_version(device))
|
|
|
|
logger.info(f"Created device {device_id} at {device_path}")
|
|
|
|
return device
|
|
|
|
def _generate_device_id(self, device_path: str, serial: Optional[str] = None) -> str:
|
|
"""Generate unique device ID.
|
|
|
|
Args:
|
|
device_path: Device path
|
|
serial: Optional serial number
|
|
|
|
Returns:
|
|
Unique device ID
|
|
"""
|
|
# Use serial number if available, otherwise use path
|
|
identifier = serial if serial else device_path
|
|
|
|
# Generate short hash
|
|
hash_obj = hashlib.md5(identifier.encode())
|
|
return f"pm3_{hash_obj.hexdigest()[:8]}"
|
|
|
|
async def _get_usb_info(self, device_path: str) -> dict:
|
|
"""Get USB device information.
|
|
|
|
Args:
|
|
device_path: Device path
|
|
|
|
Returns:
|
|
Dictionary with vid, pid, serial
|
|
"""
|
|
info = {}
|
|
|
|
if SERIAL_AVAILABLE:
|
|
try:
|
|
# Find port info
|
|
ports = serial.tools.list_ports.comports()
|
|
for port in ports:
|
|
if port.device == device_path:
|
|
if port.vid:
|
|
info["vid"] = f"{port.vid:04x}"
|
|
if port.pid:
|
|
info["pid"] = f"{port.pid:04x}"
|
|
if port.serial_number:
|
|
info["serial"] = port.serial_number
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error getting USB info: {e}")
|
|
|
|
return info
|
|
|
|
async def _query_firmware_version(self, device: PM3Device):
|
|
"""Query device firmware version.
|
|
|
|
Args:
|
|
device: PM3Device to query
|
|
"""
|
|
try:
|
|
if not device.worker:
|
|
return
|
|
|
|
# Execute hw version command
|
|
result = await device.worker.execute_command("hw version")
|
|
|
|
if result.success:
|
|
# Parse firmware version from output
|
|
firmware_info = self._parse_firmware_version(result.output)
|
|
device.firmware_info = firmware_info
|
|
|
|
# Update device status based on compatibility
|
|
if not firmware_info.compatible:
|
|
device.status = DeviceStatus.VERSION_MISMATCH
|
|
|
|
logger.info(f"Device {device.device_id} firmware: {firmware_info.os_version}")
|
|
except Exception as e:
|
|
logger.error(f"Error querying firmware version for {device.device_id}: {e}")
|
|
device.status = DeviceStatus.ERROR
|
|
|
|
def _parse_firmware_version(self, output: str) -> PM3FirmwareInfo:
|
|
"""Parse firmware version from hw version output.
|
|
|
|
Args:
|
|
output: Output from 'hw version' command
|
|
|
|
Returns:
|
|
PM3FirmwareInfo object
|
|
"""
|
|
import re
|
|
|
|
info = PM3FirmwareInfo()
|
|
|
|
# Parse bootrom version (handles Iceman format: "Iceman/master/v4.20469-104-ge509967ab-suspect")
|
|
bootrom_match = re.search(r'Bootrom[:\s.]+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
|
|
if bootrom_match:
|
|
info.bootrom_version = bootrom_match.group(1).strip()
|
|
|
|
# Parse OS version (handles Iceman format)
|
|
os_match = re.search(r'OS[:\s.]+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
|
|
if os_match:
|
|
info.os_version = os_match.group(1).strip()
|
|
|
|
# Parse client version (handles Iceman format where [ Client ] is on separate line)
|
|
# Format: "[ Client ]\n Iceman/master/4fa8f27-dirty-suspect 2026-01-04 ..."
|
|
client_match = re.search(
|
|
r'\[\s*Client\s*\]\s*\n\s*([A-Za-z]+/\w+/[^\s]+)',
|
|
output,
|
|
re.IGNORECASE | re.MULTILINE
|
|
)
|
|
if client_match:
|
|
info.client_version = client_match.group(1).strip()
|
|
|
|
# Check compatibility by comparing base version (commit hash)
|
|
# Extract the version identifier (e.g., "Iceman/master/66c7374-dirty-suspect")
|
|
# The key part is the commit hash (66c7374) - timestamps will differ between builds
|
|
def extract_base_version(ver_str: str) -> str:
|
|
"""Extract base version identifier for comparison."""
|
|
# Match patterns like:
|
|
# "Iceman/master/66c7374-dirty-suspect" (standard)
|
|
# "Iceman/DangerousPi/master/66c7374-dirty-suspect" (custom build)
|
|
# "Iceman/master/v4.20469-104-ge509967ab" (version tag)
|
|
# Capture everything up to and including the commit/version identifier
|
|
match = re.match(r'([A-Za-z]+(?:/[A-Za-z]+)?/\w+/[a-z0-9v.-]+)', ver_str)
|
|
return match.group(1) if match else ver_str
|
|
|
|
os_base = extract_base_version(info.os_version)
|
|
bootrom_base = extract_base_version(info.bootrom_version)
|
|
client_base = extract_base_version(info.client_version)
|
|
|
|
info.compatible = (
|
|
info.os_version != "unknown" and
|
|
info.bootrom_version != "unknown" and
|
|
info.client_version != "unknown" and
|
|
os_base == client_base and
|
|
bootrom_base == client_base
|
|
)
|
|
|
|
# For version comparison, extract just the version number
|
|
def extract_version(ver_str: str) -> str:
|
|
"""Extract version number from version string."""
|
|
ver_match = re.search(r'v?(\d+\.\d+)', ver_str)
|
|
return ver_match.group(1) if ver_match else ver_str
|
|
|
|
os_ver = extract_version(info.os_version)
|
|
client_ver = extract_version(info.client_version)
|
|
bootrom_ver = extract_version(info.bootrom_version)
|
|
|
|
info.needs_upgrade = os_ver < client_ver if os_ver != "unknown" and client_ver != "unknown" else False
|
|
info.needs_downgrade = os_ver > client_ver if os_ver != "unknown" and client_ver != "unknown" else False
|
|
info.bootloader_outdated = bootrom_ver < client_ver if bootrom_ver != "unknown" and client_ver != "unknown" else False
|
|
|
|
return info
|
|
|
|
def _get_local_client_version(self) -> str:
|
|
"""Get version of locally installed proxmark3 client.
|
|
|
|
Runs the client binary with --version flag to detect installed version.
|
|
|
|
Returns:
|
|
Client version string or "unknown"
|
|
"""
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
client_path = Path(config.PM3_CLIENT_PATH)
|
|
if not client_path.exists():
|
|
logger.debug(f"PM3 client not found at {client_path}")
|
|
return "unknown"
|
|
|
|
try:
|
|
# Run proxmark3 --version to get version info
|
|
result = subprocess.run(
|
|
[str(client_path), "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
output = result.stdout + result.stderr
|
|
|
|
# Parse version from output (typically like "Proxmark3 client - (RRG/Iceman v4.14831)")
|
|
import re
|
|
match = re.search(r'v[\d.]+', output)
|
|
if match:
|
|
return match.group(0)
|
|
|
|
# Fallback: look for any version-like pattern
|
|
match = re.search(r'(\d+\.\d+)', output)
|
|
if match:
|
|
return f"v{match.group(1)}"
|
|
|
|
logger.debug(f"Could not parse version from: {output[:200]}")
|
|
return "unknown"
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("Timeout getting PM3 client version")
|
|
return "unknown"
|
|
except Exception as e:
|
|
logger.debug(f"Error getting PM3 client version: {e}")
|
|
return "unknown"
|
|
|
|
async def get_device(self, device_id: str) -> Optional[PM3Device]:
|
|
"""Get device by ID.
|
|
|
|
Args:
|
|
device_id: Device ID
|
|
|
|
Returns:
|
|
PM3Device or None if not found
|
|
"""
|
|
async with self._lock:
|
|
return self._devices.get(device_id)
|
|
|
|
async def get_all_devices(self) -> List[PM3Device]:
|
|
"""Get all known devices.
|
|
|
|
Returns:
|
|
List of all PM3Device objects
|
|
"""
|
|
async with self._lock:
|
|
return list(self._devices.values())
|
|
|
|
async def get_available_devices(self) -> List[PM3Device]:
|
|
"""Get devices not currently in use.
|
|
|
|
Returns:
|
|
List of available PM3Device objects
|
|
"""
|
|
async with self._lock:
|
|
return [
|
|
device for device in self._devices.values()
|
|
if device.status == DeviceStatus.CONNECTED
|
|
]
|
|
|
|
async def get_connected_devices(self) -> List[PM3Device]:
|
|
"""Get connected devices.
|
|
|
|
Returns:
|
|
List of connected PM3Device objects
|
|
"""
|
|
async with self._lock:
|
|
return [
|
|
device for device in self._devices.values()
|
|
if device.status not in [DeviceStatus.DISCONNECTED, DeviceStatus.ERROR]
|
|
]
|
|
|
|
async def set_device_status(self, device_id: str, status: DeviceStatus) -> bool:
|
|
"""Set device status.
|
|
|
|
Args:
|
|
device_id: Device ID
|
|
status: New status
|
|
|
|
Returns:
|
|
True if successful, False if device not found
|
|
"""
|
|
async with self._lock:
|
|
device = self._devices.get(device_id)
|
|
if device:
|
|
device.status = status
|
|
return True
|
|
return False
|
|
|
|
async def identify_device(self, device_id: str, duration_ms: int = 2000):
|
|
"""Blink LEDs on device for identification.
|
|
|
|
Uses the custom hw led command (from our led-pwm-control.patch) to
|
|
flash all LEDs in a recognizable pattern.
|
|
|
|
Args:
|
|
device_id: Device ID
|
|
duration_ms: Blink duration in milliseconds
|
|
|
|
Raises:
|
|
ValueError: If device not found
|
|
RuntimeError: If identification fails
|
|
"""
|
|
device = await self.get_device(device_id)
|
|
if not device:
|
|
raise ValueError(f"Device {device_id} not found")
|
|
|
|
if not device.worker:
|
|
raise RuntimeError(f"Device {device_id} has no worker")
|
|
|
|
try:
|
|
# Calculate number of blink cycles based on duration
|
|
# Each cycle is ~400ms (200ms on + 200ms off)
|
|
cycles = max(1, duration_ms // 400)
|
|
|
|
for i in range(cycles):
|
|
# Turn all LEDs on
|
|
result = await device.worker.execute_command("hw led --led a,b,c,d --on")
|
|
if not result.success:
|
|
logger.warning(f"hw led on failed: {result.error}")
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
# Turn all LEDs off
|
|
result = await device.worker.execute_command("hw led --led a,b,c,d --off")
|
|
if not result.success:
|
|
logger.warning(f"hw led off failed: {result.error}")
|
|
|
|
if i < cycles - 1:
|
|
await asyncio.sleep(0.2)
|
|
|
|
logger.info(f"Identified device {device_id} with {cycles} LED blink cycles")
|
|
except Exception as e:
|
|
logger.error(f"Error identifying device {device_id}: {e}")
|
|
raise RuntimeError(f"Failed to identify device: {e}")
|
|
|
|
async def flash_firmware(self, device_id: str) -> dict:
|
|
"""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:
|
|
dict with success status and message
|
|
"""
|
|
from ..websocket.notifications import notify_pm3_flash_progress
|
|
|
|
device = await self.get_device(device_id)
|
|
if not device:
|
|
return {"success": False, "error": "Device not found"}
|
|
|
|
# Check device is not already flashing
|
|
if device.status == DeviceStatus.FLASHING:
|
|
return {"success": False, "error": "Device is already being flashed"}
|
|
|
|
# Check device is not in use
|
|
if device.status == DeviceStatus.IN_USE:
|
|
return {"success": False, "error": "Device is in use. End session first."}
|
|
|
|
# Firmware paths
|
|
firmware_dir = Path(config.FIRMWARE_DIR)
|
|
bootrom_path = firmware_dir / "bootrom.elf"
|
|
fullimage_path = firmware_dir / "fullimage.elf"
|
|
|
|
# Validate firmware files exist
|
|
if not bootrom_path.exists():
|
|
return {"success": False, "error": f"Bootrom firmware not found at {bootrom_path}"}
|
|
if not fullimage_path.exists():
|
|
return {"success": False, "error": f"Fullimage firmware not found at {fullimage_path}"}
|
|
|
|
# Set device to flashing state
|
|
original_status = device.status
|
|
device.status = DeviceStatus.FLASHING
|
|
await self._notify_device_change(force=True)
|
|
|
|
try:
|
|
# Send starting notification
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="starting",
|
|
progress=0,
|
|
message="Preparing to flash firmware..."
|
|
)
|
|
|
|
# Find pm3-flash-all utility
|
|
pm3_flash_path = await self._find_pm3_flash_utility()
|
|
if not pm3_flash_path:
|
|
raise RuntimeError("pm3-flash-all utility not found")
|
|
|
|
# Phase 1: Flash bootrom (0-40%)
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="bootrom",
|
|
progress=10,
|
|
message="Flashing bootrom..."
|
|
)
|
|
|
|
bootrom_result = await self._execute_flash_command(
|
|
pm3_flash_path,
|
|
device.device_path,
|
|
str(bootrom_path),
|
|
"bootrom",
|
|
device_id
|
|
)
|
|
|
|
if not bootrom_result["success"]:
|
|
raise RuntimeError(f"Bootrom flash failed: {bootrom_result['error']}")
|
|
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="bootrom",
|
|
progress=40,
|
|
message="Bootrom flashed successfully"
|
|
)
|
|
|
|
# Brief delay for device to restart
|
|
await asyncio.sleep(2)
|
|
|
|
# Phase 2: Flash fullimage (50-90%)
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="fullimage",
|
|
progress=50,
|
|
message="Flashing fullimage..."
|
|
)
|
|
|
|
fullimage_result = await self._execute_flash_command(
|
|
pm3_flash_path,
|
|
device.device_path,
|
|
str(fullimage_path),
|
|
"fullimage",
|
|
device_id
|
|
)
|
|
|
|
if not fullimage_result["success"]:
|
|
raise RuntimeError(f"Fullimage flash failed: {fullimage_result['error']}")
|
|
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="fullimage",
|
|
progress=90,
|
|
message="Fullimage flashed successfully"
|
|
)
|
|
|
|
# Phase 3: Verify (95%)
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="verifying",
|
|
progress=95,
|
|
message="Verifying firmware..."
|
|
)
|
|
|
|
# Wait for device to restart
|
|
await asyncio.sleep(3)
|
|
|
|
# Re-query firmware version
|
|
await self._query_firmware_version(device)
|
|
|
|
# Check if now compatible
|
|
if device.firmware_info.compatible:
|
|
device.status = DeviceStatus.CONNECTED
|
|
message = "Firmware updated successfully"
|
|
else:
|
|
device.status = DeviceStatus.VERSION_MISMATCH
|
|
message = "Firmware flashed but version mismatch persists"
|
|
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="complete",
|
|
progress=100,
|
|
message=message
|
|
)
|
|
|
|
await self._notify_device_change(force=True)
|
|
|
|
logger.info(f"Successfully flashed firmware to device {device_id}")
|
|
return {"success": True, "message": message}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Firmware flash failed for device {device_id}: {e}")
|
|
|
|
# Notify of error
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status="error",
|
|
progress=0,
|
|
message=str(e)
|
|
)
|
|
|
|
# Restore original status or set to error
|
|
device.status = DeviceStatus.ERROR
|
|
await self._notify_device_change(force=True)
|
|
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def _find_pm3_flash_utility(self) -> Optional[str]:
|
|
"""Find pm3-flash-all or pm3-flash utility.
|
|
|
|
Returns:
|
|
Path to flash utility or None if not found
|
|
"""
|
|
import shutil
|
|
|
|
# Search locations in order of preference
|
|
search_paths = [
|
|
# Bundled with dangerous-pi
|
|
Path(config.PM3_CLIENT_PATH).parent / "pm3-flash-all" if hasattr(config, 'PM3_CLIENT_PATH') else None,
|
|
# Default installation
|
|
Path("/home/dt/.pm3/proxmark3/pm3-flash-all"),
|
|
Path("/home/dt/.pm3/proxmark3/client/flasher"),
|
|
# System path
|
|
Path(shutil.which("pm3-flash-all") or ""),
|
|
Path(shutil.which("flasher") or ""),
|
|
]
|
|
|
|
for path in search_paths:
|
|
if path and path.exists() and path.is_file():
|
|
logger.info(f"Found pm3 flash utility at: {path}")
|
|
return str(path)
|
|
|
|
logger.warning("pm3 flash utility not found in standard locations")
|
|
return None
|
|
|
|
async def _execute_flash_command(
|
|
self,
|
|
flash_path: str,
|
|
device_path: str,
|
|
firmware_path: str,
|
|
phase: str,
|
|
device_id: str
|
|
) -> dict:
|
|
"""Execute flash command and parse output.
|
|
|
|
Args:
|
|
flash_path: Path to flash utility
|
|
device_path: Device path (e.g., /dev/ttyACM0)
|
|
firmware_path: Path to .elf firmware file
|
|
phase: "bootrom" or "fullimage"
|
|
device_id: Device ID for progress notifications
|
|
|
|
Returns:
|
|
dict with success status
|
|
"""
|
|
from ..websocket.notifications import notify_pm3_flash_progress
|
|
|
|
# Build command
|
|
# pm3-flash-all expects: pm3-flash-all -p /dev/ttyACMx [bootrom.elf] [fullimage.elf]
|
|
# But we're doing them separately for better progress tracking
|
|
cmd = [flash_path, "-p", device_path, firmware_path]
|
|
|
|
logger.info(f"Executing flash command: {' '.join(cmd)}")
|
|
|
|
try:
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT
|
|
)
|
|
|
|
output_lines = []
|
|
base_progress = 10 if phase == "bootrom" else 50
|
|
max_progress = 40 if phase == "bootrom" else 90
|
|
|
|
# Read output line by line and parse progress
|
|
while True:
|
|
line = await process.stdout.readline()
|
|
if not line:
|
|
break
|
|
|
|
line_str = line.decode('utf-8', errors='replace').strip()
|
|
output_lines.append(line_str)
|
|
logger.debug(f"Flash output: {line_str}")
|
|
|
|
# Parse progress from output
|
|
progress = self._parse_flash_progress(line_str, base_progress, max_progress)
|
|
if progress:
|
|
await notify_pm3_flash_progress(
|
|
device_id=device_id,
|
|
status=phase,
|
|
progress=progress,
|
|
message=f"Flashing {phase}..."
|
|
)
|
|
|
|
await process.wait()
|
|
|
|
output = "\n".join(output_lines)
|
|
|
|
if process.returncode == 0:
|
|
return {"success": True, "output": output}
|
|
else:
|
|
# Check for common errors in output
|
|
error_msg = self._extract_flash_error(output)
|
|
return {"success": False, "error": error_msg or f"Flash exited with code {process.returncode}"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Flash command execution failed: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
def _parse_flash_progress(self, line: str, base: int, max_val: int) -> Optional[int]:
|
|
"""Parse progress percentage from flash output line.
|
|
|
|
Args:
|
|
line: Output line from flash process
|
|
base: Base progress percentage
|
|
max_val: Maximum progress percentage
|
|
|
|
Returns:
|
|
Progress percentage or None
|
|
"""
|
|
import re
|
|
|
|
# Look for percentage patterns like "50%" or "Writing: 50%"
|
|
percent_match = re.search(r'(\d+)%', line)
|
|
if percent_match:
|
|
raw_percent = int(percent_match.group(1))
|
|
# Scale to our range
|
|
scaled = base + (raw_percent / 100) * (max_val - base)
|
|
return int(scaled)
|
|
|
|
# Look for block-based progress like "Writing block 10/20"
|
|
block_match = re.search(r'(\d+)\s*/\s*(\d+)', line)
|
|
if block_match and 'block' in line.lower():
|
|
current = int(block_match.group(1))
|
|
total = int(block_match.group(2))
|
|
if total > 0:
|
|
raw_percent = (current / total) * 100
|
|
scaled = base + (raw_percent / 100) * (max_val - base)
|
|
return int(scaled)
|
|
|
|
return None
|
|
|
|
def _extract_flash_error(self, output: str) -> Optional[str]:
|
|
"""Extract error message from flash output.
|
|
|
|
Args:
|
|
output: Full flash output
|
|
|
|
Returns:
|
|
Error message or None
|
|
"""
|
|
error_patterns = [
|
|
r"error[:\s]+(.+?)(?:\n|$)",
|
|
r"failed[:\s]+(.+?)(?:\n|$)",
|
|
r"cannot\s+(.+?)(?:\n|$)",
|
|
r"permission denied",
|
|
r"device not found",
|
|
r"timeout",
|
|
]
|
|
|
|
import re
|
|
for pattern in error_patterns:
|
|
match = re.search(pattern, output, re.IGNORECASE)
|
|
if match:
|
|
return match.group(0).strip()
|
|
|
|
return None
|
|
|
|
async def _start_udev_monitor(self):
|
|
"""Start udev monitoring for device hotplug events."""
|
|
try:
|
|
logger.info("Starting udev device monitor")
|
|
|
|
# This will be implemented in a separate task
|
|
# For now, fall back to polling
|
|
await self._start_polling_monitor()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error starting udev monitor: {e}")
|
|
await self._start_polling_monitor()
|
|
|
|
async def _start_polling_monitor(self):
|
|
"""Start polling-based device monitoring."""
|
|
logger.info(f"Starting polling device monitor (interval: {self.discovery_interval}s)")
|
|
|
|
async def poll_devices():
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(self.discovery_interval)
|
|
await self.discover_devices()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error in device polling: {e}")
|
|
|
|
self._monitor_task = asyncio.create_task(poll_devices())
|