Files
pi-pm3/app/backend/managers/pm3_device_manager.py
michael 4f35df1781 Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:46:22 -08:00

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())