🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
85 KiB
REFERENCE DOCUMENT: For current progress and remaining tasks, see REFACTORING_ROADMAP.md. This document contains detailed design decisions and is kept as the authoritative reference for multi-PM3 architecture.
Multi-Proxmark3 Support - Refactoring Plan
Date: 2025-11-26 Target Device: Proxmark3 Easy (Iceman Firmware) Objective: Support N Proxmark3 devices connected via USB hub with LED identification Status: 65% Implemented - Core device management complete, SessionManager update pending
Executive Summary
This plan outlines the refactoring required to transform Dangerous Pi from a single-PM3 platform to a multi-PM3 platform capable of:
Core Capabilities
- Detecting and managing N Proxmark3 Easy devices simultaneously
- Real-time device hotplug detection via udev (with polling fallback)
- Visual LED identification of selected devices for physical identification
- Strict firmware version management with zero tolerance for mismatches
- Seamless device selection across REST API, BLE, and frontend interfaces
- Session conflict resolution with alternative device selection or takeover
- Batch firmware updates for maintaining device fleet consistency
- Battery-aware operations with safety checks for firmware flashing
Key Design Decisions (User-Approved)
Device Management:
- Interface-based auto-naming (ttyACM0, ttyACM1) + user customization
- Udev event-driven detection (no polling overhead)
- No session persistence across restarts
- Graceful "no devices connected" empty state
Firmware Strategy:
- STRICT version enforcement - exact match required, no tolerance
- Bundled firmware as primary source for project stability
- Auto-update all devices when Dangerous Pi system upgrades
- Battery ≥80% required for bootloader flashing
- Dynamic parallel flashing based on AC/battery power
Safety Features:
- Power source detection (AC vs battery)
- Battery level monitoring during operations
- USB hub overload warnings (>2 devices)
- JTAG recovery documentation for bricked devices
User Experience:
- Zero-config device detection
- One-click "Update All Devices" for fleet management
- Real-time progress tracking for firmware operations
- Clear visual indicators for device status (connected, in-use, version mismatch, disabled)
Current Architecture Analysis
Current State
- Single Device Assumption: Hardcoded
/dev/ttyACM0in config - Single Worker: One
PM3Workerinstance in service container - Session Manager: Designed for single-user, single-device access
- Frontend: No device selection UI
- BLE: No device selection support
Key Components Affected
- PM3Worker (
app/backend/workers/pm3_worker.py) - PM3Service (
app/backend/services/pm3_service.py) - SessionManager (
app/backend/managers/session_manager.py) - ServiceContainer (
app/backend/services/container.py) - Frontend UI (all routes)
- BLE GATT Server (
app/backend/ble/gatt_server.py) - API Endpoints (
app/backend/api/pm3.py,app/backend/api/system.py)
LED Control Research Summary
PM3 Easy LED Capabilities
Hardware:
- 4 status LEDs: A, B, C, D (Red, Orange, Green, Red2)
- 1 power LED (not controllable)
- 1 button
Firmware LED Control Functions:
// From armsrc/util.h
void LED(int led, int ms); // Control individual LED
void LEDsoff(); // Turn off all LEDs
void LEDson(); // Turn on all LEDs
void LEDsinvert(); // Invert all LED states
// LED Constants
#define LED_RED 1 // LED A
#define LED_ORANGE 2 // LED B
#define LED_GREEN 4 // LED C
#define LED_RED2 8 // LED D
Current CLI Status:
- ❌ No built-in
hw ledcommand in standard PM3 client - ✅ LEDs controllable via firmware code
- ✅ Python pm3 module can execute commands
- 🔧 Implementation Required: Custom command or firmware modification
LED Identification Strategy
Option A: Firmware Modification (Recommended)
- Add
hw ledcommand to PM3 client/firmware - Syntax:
hw led --set <led_mask> --duration <ms> - Example:
hw led --set 15 --duration 1000(blink all LEDs for 1s) - Pros: Clean, reusable, follows PM3 conventions
- Cons: Requires firmware compilation and flashing
Option B: Direct USB Communication
- Send raw USB packets to control LEDs
- Bypass PM3 client command interface
- Pros: No firmware changes needed
- Cons: Complex, fragile, firmware-version dependent
Option C: Workaround with Existing Commands
- Use existing commands that trigger LED patterns
- Example:
hw tunebriefly activates LEDs - Pros: No firmware changes
- Cons: Inconsistent, slower, not reliable for identification
Recommendation: Option A with Option C as fallback during development
Firmware Version Management
Overview
With multiple PM3 devices, firmware version synchronization becomes critical. Dangerous Pi must:
- Detect firmware version on each device
- Compare against expected/local client version
- Notify users of mismatches
- Provide flashing capabilities
- Handle devices safely during firmware operations
Version Detection Strategy
On Device Discovery:
- Query
hw versioncommand - Parse firmware version, bootloader version, client version
- Compare against local PM3 client version
- Set device status based on compatibility
Version Information Structure:
@dataclass
class PM3FirmwareInfo:
"""Firmware version information."""
bootrom_version: str # e.g., "v4.14831"
os_version: str # e.g., "v4.14831"
client_version: str # Local client version
compatible: bool # Versions match
needs_upgrade: bool # Device firmware older
needs_downgrade: bool # Device firmware newer
bootloader_outdated: bool # Bootloader needs update
Version Comparison Logic
Compatibility Rules:
class FirmwareCompatibility:
"""Firmware version compatibility checker."""
@staticmethod
def check_compatibility(
device_version: str,
client_version: str
) -> CompatibilityResult:
"""Check if device firmware is compatible with client.
Args:
device_version: Device firmware version (e.g., "v4.14831")
client_version: Local client version (e.g., "v4.14831")
Returns:
CompatibilityResult with status and recommended action
"""
# Parse semantic versions
device_major, device_minor, device_patch = parse_version(device_version)
client_major, client_minor, client_patch = parse_version(client_version)
# Major version must match
if device_major != client_major:
return CompatibilityResult(
compatible=False,
severity="critical",
action="flash_required",
message=f"Major version mismatch: {device_version} vs {client_version}"
)
# Minor version mismatch is a warning
if device_minor != client_minor:
return CompatibilityResult(
compatible=True, # Works but not ideal
severity="warning",
action="flash_recommended",
message=f"Minor version mismatch: {device_version} vs {client_version}"
)
# Patch version difference is acceptable
if device_patch != client_patch:
return CompatibilityResult(
compatible=True,
severity="info",
action="flash_optional",
message=f"Patch version difference: {device_version} vs {client_version}"
)
# Perfect match
return CompatibilityResult(
compatible=True,
severity="ok",
action="none",
message="Firmware versions match"
)
Device Status Based on Firmware
Device States:
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
UI Treatment:
CONNECTED: Green indicator, selectableVERSION_MISMATCH: Yellow indicator, show warning banner, offer flashDISABLED: Gray indicator, show "Update firmware to enable" buttonFLASHING: Blue indicator with progress bar, not selectableBOOTLOADER_MODE: Orange indicator, show "Flash firmware" action
Firmware Flashing Integration
Flashing Tools Detection
New File: app/backend/utils/pm3_flasher.py
"""Proxmark3 firmware flashing utilities."""
import asyncio
import subprocess
from pathlib import Path
from typing import Optional, Callable
class PM3Flasher:
"""Firmware flashing for Proxmark3 devices."""
def __init__(self):
self.flash_tools = self._detect_flash_tools()
self.firmware_dir = self._find_firmware_directory()
def _detect_flash_tools(self) -> dict:
"""Detect available PM3 flashing tools.
Returns:
Dict of tool paths: {
'pm3_flash_all': '/usr/bin/pm3-flash-all',
'pm3_client': '/usr/bin/proxmark3',
'flasher': '/usr/bin/flasher'
}
"""
tools = {}
# Check for modern pm3-flash-* scripts
for tool in ['pm3-flash-all', 'pm3-flash-bootrom', 'pm3-flash-fullimage']:
path = shutil.which(tool)
if path:
tools[tool] = path
# Check for proxmark3 client with --flash support
pm3_path = shutil.which('proxmark3')
if pm3_path:
tools['proxmark3'] = pm3_path
# Check for legacy flasher tool
flasher_path = shutil.which('flasher')
if flasher_path:
tools['flasher'] = flasher_path
return tools
def _find_firmware_directory(self) -> Optional[Path]:
"""Locate firmware files.
Standard locations:
- /usr/share/proxmark3/firmware/
- /opt/proxmark3/firmware/
- ./firmware/ (development)
Returns:
Path to firmware directory or None
"""
candidates = [
Path('/usr/share/proxmark3/firmware'),
Path('/opt/proxmark3/firmware'),
Path('/usr/local/share/proxmark3/firmware'),
Path('./firmware'),
]
for path in candidates:
if path.exists() and (path / 'fullimage.elf').exists():
return path
return None
async def flash_device(
self,
device_path: str,
flash_bootrom: bool = False,
progress_callback: Optional[Callable[[int, str], None]] = None
) -> FlashResult:
"""Flash firmware to device.
Args:
device_path: Device path (e.g., /dev/ttyACM0)
flash_bootrom: Also flash bootloader (dangerous!)
progress_callback: Callback for progress updates (percent, message)
Returns:
FlashResult with success status and details
"""
if not self.firmware_dir:
return FlashResult(
success=False,
error="Firmware files not found. Please install proxmark3 firmware."
)
try:
# Step 1: Check if device is in bootloader mode
in_bootloader = await self._is_bootloader_mode(device_path)
if not in_bootloader:
# Need to enter bootloader mode
if progress_callback:
progress_callback(10, "Entering bootloader mode...")
await self._enter_bootloader_mode(device_path)
await asyncio.sleep(2) # Wait for bootloader
# Step 2: Flash bootrom if requested (DANGEROUS!)
if flash_bootrom:
if progress_callback:
progress_callback(20, "Flashing bootloader (this may take a while)...")
bootrom_result = await self._flash_bootrom(device_path)
if not bootrom_result.success:
return FlashResult(
success=False,
error=f"Bootloader flash failed: {bootrom_result.error}"
)
await asyncio.sleep(2)
# Step 3: Flash fullimage
if progress_callback:
progress_callback(60, "Flashing firmware...")
fullimage_result = await self._flash_fullimage(device_path, progress_callback)
if not fullimage_result.success:
return FlashResult(
success=False,
error=f"Firmware flash failed: {fullimage_result.error}"
)
# Step 4: Verify
if progress_callback:
progress_callback(90, "Verifying firmware...")
await asyncio.sleep(2) # Wait for reboot
version = await self._verify_flash(device_path)
if progress_callback:
progress_callback(100, "Flash complete!")
return FlashResult(
success=True,
new_version=version,
message="Firmware flashed successfully"
)
except Exception as e:
return FlashResult(
success=False,
error=f"Flash failed: {str(e)}"
)
async def _flash_fullimage(
self,
device_path: str,
progress_callback: Optional[Callable] = None
) -> FlashResult:
"""Flash fullimage.elf to device."""
fullimage_path = self.firmware_dir / 'fullimage.elf'
if 'proxmark3' in self.flash_tools:
# Modern flashing method
cmd = [
self.flash_tools['proxmark3'],
device_path,
'--flash',
'--image', str(fullimage_path)
]
elif 'pm3-flash-fullimage' in self.flash_tools:
# Helper script method
cmd = [self.flash_tools['pm3-flash-fullimage']]
else:
return FlashResult(success=False, error="No flash tool available")
# Execute flash command
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
return FlashResult(success=True)
else:
return FlashResult(
success=False,
error=stderr.decode() if stderr else "Unknown error"
)
async def _is_bootloader_mode(self, device_path: str) -> bool:
"""Check if device is in bootloader mode.
Bootloader mode indicators:
- Red and yellow LEDs stay lit
- Device responds to bootloader commands
"""
try:
# Try to communicate with device
# If it responds to normal commands, not in bootloader
import pm3
device = pm3.open(device_path)
# If this succeeds, device is in normal mode
return False
except:
# Failed to open normally, might be in bootloader
# Check for bootloader USB descriptor
return True
async def _enter_bootloader_mode(self, device_path: str):
"""Put device into bootloader mode.
Methods:
1. Send special command (if firmware supports it)
2. Instruct user to manually enter bootloader
"""
# TODO: Implement automatic bootloader entry
# For now, requires manual intervention
raise NotImplementedError(
"Automatic bootloader entry not implemented. "
"Please manually enter bootloader mode: "
"Press and hold button while connecting device."
)
User Interface for Version Mismatch
Device Card Enhancement
// In DeviceSelector component
interface Device {
// ... existing fields
firmware_info: {
os_version: string;
bootrom_version: string;
client_version: string;
compatible: boolean;
compatibility_status: 'ok' | 'warning' | 'critical';
action: 'none' | 'flash_optional' | 'flash_recommended' | 'flash_required';
};
status: DeviceStatus;
}
function DeviceCard({ device, onFlash, onIgnore }: DeviceCardProps) {
const getStatusColor = () => {
if (device.status === 'version_mismatch') return 'var(--color-warning)';
if (device.status === 'disabled') return 'var(--color-text-muted)';
if (device.status === 'connected') return 'var(--color-success)';
// ... etc
};
const showVersionWarning = device.firmware_info.compatibility_status !== 'ok';
return (
<div className="device-card" style={{ borderColor: getStatusColor() }}>
{/* ... device info ... */}
{showVersionWarning && (
<div className="version-warning" style={{
marginTop: 'var(--space-2)',
padding: 'var(--space-2)',
background: 'rgba(255, 200, 0, 0.1)',
borderLeft: '3px solid var(--color-warning)',
borderRadius: 'var(--radius)'
}}>
<div style={{ fontWeight: 600, marginBottom: 'var(--space-1)' }}>
⚠ Firmware Version Mismatch
</div>
<div style={{ fontSize: '0.875rem', marginBottom: 'var(--space-2)' }}>
Device: {device.firmware_info.os_version} •
Client: {device.firmware_info.client_version}
</div>
{device.status !== 'disabled' && (
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<button
className="btn btn-primary"
style={{ fontSize: '0.875rem' }}
onClick={() => onFlash(device.device_id)}
>
<span>⚡</span>
<span>Update Firmware</span>
</button>
<button
className="btn btn-secondary"
style={{ fontSize: '0.875rem' }}
onClick={() => onIgnore(device.device_id)}
>
<span>Ignore</span>
</button>
</div>
)}
{device.status === 'disabled' && (
<button
className="btn btn-secondary"
style={{ fontSize: '0.875rem' }}
onClick={() => onFlash(device.device_id)}
>
<span>⚡</span>
<span>Enable by Updating Firmware</span>
</button>
)}
</div>
)}
</div>
);
}
Firmware Flash Dialog
New File: app/frontend/app/components/FirmwareFlashDialog.tsx
interface FirmwareFlashDialogProps {
device: Device;
onClose: () => void;
}
export default function FirmwareFlashDialog({ device, onClose }: Props) {
const [flashBootrom, setFlashBootrom] = useState(false);
const [flashing, setFlashing] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('');
const [error, setError] = useState<string | null>(null);
const handleFlash = async () => {
setFlashing(true);
setProgress(0);
setError(null);
try {
const response = await fetch(`/api/pm3/devices/${device.device_id}/flash`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ flash_bootrom: flashBootrom })
});
// Poll for progress
const progressInterval = setInterval(async () => {
const progressResponse = await fetch(
`/api/pm3/devices/${device.device_id}/flash/progress`
);
const data = await progressResponse.json();
setProgress(data.progress);
setStatus(data.status);
if (data.complete) {
clearInterval(progressInterval);
setFlashing(false);
if (data.success) {
// Success!
setTimeout(() => onClose(), 2000);
} else {
setError(data.error);
}
}
}, 1000);
} catch (err) {
setError(`Flash failed: ${err}`);
setFlashing(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="card" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
<h3 className="card-title">Update Firmware</h3>
<div style={{ marginBottom: 'var(--space-4)' }}>
<strong>Device:</strong> {device.friendly_name || device.device_path}
<br />
<strong>Current Firmware:</strong> {device.firmware_info.os_version}
<br />
<strong>Target Firmware:</strong> {device.firmware_info.client_version}
</div>
{!flashing && (
<>
<div className="form-group">
<label>
<input
type="checkbox"
checked={flashBootrom}
onChange={(e) => setFlashBootrom(e.target.checked)}
/>
<span style={{ marginLeft: 'var(--space-2)' }}>
Also update bootloader (advanced)
</span>
</label>
{flashBootrom && (
<div className="warning" style={{
marginTop: 'var(--space-2)',
padding: 'var(--space-2)',
background: 'rgba(255, 100, 100, 0.1)',
borderLeft: '3px solid var(--color-error)'
}}>
⚠ Warning: Bootloader flashing can brick your device if interrupted!
</div>
)}
</div>
<div style={{ display: 'flex', gap: 'var(--space-2)', marginTop: 'var(--space-4)' }}>
<button className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button className="btn btn-primary" onClick={handleFlash}>
<span>⚡</span>
<span>Flash Firmware</span>
</button>
</div>
</>
)}
{flashing && (
<>
<div style={{ marginBottom: 'var(--space-3)' }}>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<div style={{ textAlign: 'center', marginTop: 'var(--space-2)' }}>
{progress}% - {status}
</div>
</div>
<div style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>
Do not disconnect the device or close this window during flashing.
</div>
</>
)}
{error && (
<div className="error" style={{
marginTop: 'var(--space-3)',
padding: 'var(--space-3)',
background: 'rgba(255, 100, 100, 0.1)',
border: '1px solid var(--color-error)'
}}>
{error}
</div>
)}
</div>
</div>
);
}
API Endpoints for Firmware Management
Changes to: app/backend/api/pm3.py
# NEW ENDPOINTS
@router.post("/devices/{device_id}/flash")
async def flash_firmware(
device_id: str,
request: FlashRequest
):
"""Flash firmware to device.
Args:
device_id: Device ID
request: Flash options (flash_bootrom, etc.)
Returns:
Flash job ID for tracking progress
"""
result = await container.pm3_service.flash_firmware(
device_id=device_id,
flash_bootrom=request.flash_bootrom
)
return {
"success": result.success,
"job_id": result.data.get("job_id") if result.success else None,
"error": result.error.message if not result.success else None
}
@router.get("/devices/{device_id}/flash/progress")
async def get_flash_progress(device_id: str, job_id: str):
"""Get firmware flash progress."""
result = await container.pm3_service.get_flash_progress(
device_id=device_id,
job_id=job_id
)
return result.data
@router.post("/devices/{device_id}/ignore-version-mismatch")
async def ignore_version_mismatch(device_id: str):
"""Mark device as disabled due to version mismatch."""
result = await container.pm3_service.set_device_status(
device_id=device_id,
status=DeviceStatus.DISABLED
)
return {"success": result.success}
@router.get("/firmware/info")
async def get_local_firmware_info():
"""Get local firmware version and availability."""
result = await container.pm3_service.get_local_firmware_info()
return {
"client_version": result.data.get("client_version"),
"firmware_available": result.data.get("firmware_files_found"),
"firmware_path": result.data.get("firmware_path"),
"can_flash": result.data.get("flash_tools_available")
}
Additional Considerations
1. Bootloader Detection & Safety
Issue: Devices in bootloader mode vs. normal mode appear differently on USB Solution:
# In PM3DeviceManager
async def detect_bootloader_devices(self) -> List[PM3Device]:
"""Detect devices in bootloader mode.
Bootloader devices:
- May appear at different USB endpoint
- Don't respond to normal pm3 commands
- Show specific LED pattern (red/yellow on)
"""
# Check USB devices with bootloader VID/PID
# Different from normal operation VID/PID
pass
UI Treatment:
- Show bootloader devices separately
- Indicate "Ready to flash" status
- Don't allow command execution
- Provide "Flash Firmware" button
2. Firmware File Management
Issue: Where to store/source firmware files? Options:
Option A: Use System-Installed Firmware
# Firmware from proxmark3 package installation
FIRMWARE_PATHS = [
'/usr/share/proxmark3/firmware/',
'/opt/proxmark3/firmware/'
]
- Pros: No duplication, matches client version
- Cons: Requires proxmark3 package installed
Option B: Bundle Firmware with Dangerous Pi
dangerous-pi/
firmware/
bootrom.elf
fullimage.elf
version.txt
- Pros: Self-contained, always available
- Cons: Needs updating when PM3 client updates
- Cons: Licensing considerations (GPL)
Option C: Download on Demand
# Download from Proxmark3 releases
await download_firmware(
version=desired_version,
target_dir=FIRMWARE_CACHE
)
- Pros: Always up-to-date
- Cons: Requires internet
- Cons: May be slow
Recommendation: Option A with Option B as fallback
3. Multi-Device Flash Operations
Issue: User has 5 devices, all need updates Solution: Batch flash operation
async def flash_multiple_devices(
device_ids: List[str],
flash_bootrom: bool = False,
sequential: bool = True # vs parallel
) -> List[FlashResult]:
"""Flash multiple devices.
Args:
device_ids: List of device IDs to flash
flash_bootrom: Also flash bootloader
sequential: Flash one at a time (safer) vs parallel
Returns:
List of flash results
"""
if sequential:
results = []
for device_id in device_ids:
result = await flash_device(device_id, flash_bootrom)
results.append(result)
await asyncio.sleep(2) # Brief pause between devices
return results
else:
# Parallel flashing (risky - high USB bus load)
tasks = [
flash_device(device_id, flash_bootrom)
for device_id in device_ids
]
return await asyncio.gather(*tasks)
UI: "Update All Devices" button with batch progress
4. Firmware Rollback/Recovery
Issue: Flash fails, device bricked Solutions:
- Backup before flash (if possible)
- JTAG recovery instructions for worst case
- Bootloader preservation - avoid flashing bootrom unless necessary
- Verification before completion
async def flash_with_safety(device_path: str):
"""Flash with safety checks and rollback."""
# 1. Verify device is responding
await verify_device_communication(device_path)
# 2. Read current firmware version (for logs)
old_version = await get_firmware_version(device_path)
# 3. Flash firmware
flash_result = await flash_fullimage(device_path)
# 4. Verify new firmware
if flash_result.success:
await asyncio.sleep(2)
new_version = await get_firmware_version(device_path)
if new_version is None:
return FlashResult(
success=False,
error="Flash verification failed - device not responding"
)
return flash_result
5. Version Mismatch Persistence
Issue: User clicks "Ignore" but setting isn't saved Solution: Store in database
-- Add to devices table
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
-- ... existing fields
version_mismatch_ignored BOOLEAN DEFAULT FALSE,
ignored_at TIMESTAMP,
ignored_version TEXT -- Which version was ignored
);
Behavior:
- User clicks "Ignore" → Set
version_mismatch_ignored = TRUE - On next detection, check if version changed
- If version same as ignored_version → Keep disabled
- If version different → Re-prompt user
6. Client Version Detection
Issue: How to determine "correct" firmware version? Solution:
def get_local_client_version() -> str:
"""Get version of locally installed proxmark3 client.
Methods (in order of preference):
1. Execute `proxmark3 --version`
2. Check package manager (dpkg, rpm, etc.)
3. Parse version file if bundled
"""
try:
result = subprocess.run(
['proxmark3', '--version'],
capture_output=True,
text=True,
timeout=5
)
# Parse output: "Proxmark3 RFID instrument\n client: RRG/Iceman/master/v4.14831"
match = re.search(r'v(\d+\.\d+)', result.stdout)
if match:
return match.group(1)
except:
pass
# Fallback to package manager
# ...
return "unknown"
7. BLE Firmware Notifications
Issue: Users on mobile need firmware update notifications Solution:
# In BLE GATT Server
async def notify_firmware_mismatch(device_id: str, device_info: Device):
"""Notify BLE clients of firmware mismatch."""
notification = {
"type": "firmware_mismatch",
"device_id": device_id,
"device_version": device_info.firmware_info.os_version,
"client_version": device_info.firmware_info.client_version,
"severity": device_info.firmware_info.compatibility_status,
"action_required": device_info.firmware_info.action
}
await self._notify_characteristic(
PM3CharacteristicUUIDs.FIRMWARE_STATUS,
json.dumps(notification).encode('utf-8')
)
Note: BLE clients can't directly flash firmware (requires USB), but can:
- Be notified of mismatch
- Be directed to web UI for flashing
- See device status
8. Flashing Progress via SSE
Issue: Long-running flash operation needs real-time updates Solution: Server-Sent Events
# In SSE event broadcaster
async def broadcast_flash_progress(
device_id: str,
progress: int,
status: str
):
"""Broadcast flash progress to all connected clients."""
await event_broadcaster.send_event({
"type": "flash_progress",
"device_id": device_id,
"progress": progress,
"status": status
})
Frontend:
useEffect(() => {
const eventSource = new EventSource('/api/sse/events');
eventSource.addEventListener('flash_progress', (event) => {
const data = JSON.parse(event.data);
if (data.device_id === selectedDevice) {
setFlashProgress(data.progress);
setFlashStatus(data.status);
}
});
return () => eventSource.close();
}, [selectedDevice]);
9. Automatic Version Check on Connect
Issue: User connects new device mid-session Solution: Automatic device discovery with version check
# In PM3DeviceManager
async def start_device_monitor(self):
"""Monitor USB bus for device changes."""
while True:
# Scan for devices every 30 seconds
await asyncio.sleep(30)
current_devices = await self.discover_devices()
# Check for new devices
for device in current_devices:
if device.device_id not in self._known_devices:
# New device detected!
logger.info(f"New PM3 device detected: {device.device_id}")
# Check firmware version
compat = check_firmware_compatibility(device)
# Notify clients via SSE/BLE
await notify_new_device(device, compat)
# Check for removed devices
# ...
10. Firmware Update Logs
Issue: Need audit trail of firmware updates Solution: Log all flash operations
# Database schema
CREATE TABLE IF NOT EXISTS firmware_flash_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
device_path TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
old_version TEXT,
new_version TEXT,
flash_bootrom BOOLEAN,
success BOOLEAN,
error_message TEXT,
duration_seconds INTEGER,
user_ip TEXT
);
Benefits:
- Troubleshooting flash failures
- Audit compliance
- Statistics (how often devices need updates)
Refactoring Plan
1.1 Create Device Manager
New File: app/backend/managers/pm3_device_manager.py
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: str # USB serial number (if available)
friendly_name: str # User-assigned name (e.g., "PM3-Living Room")
usb_vid: str # USB Vendor ID (0x9AC4 or 0x502D)
usb_pid: str # USB Product ID (0x4B8F or 0x502D)
status: DeviceStatus # CONNECTED, DISCONNECTED, IN_USE, ERROR
version: str # Firmware version (from hw version)
last_seen: datetime # Last detection timestamp
worker: PM3Worker # Dedicated worker instance
class PM3DeviceManager:
"""Manages multiple Proxmark3 devices."""
async def discover_devices() -> List[PM3Device]:
"""Scan USB ports for PM3 devices."""
# 1. Enumerate /dev/ttyACM* devices
# 2. Filter by USB VID/PID (0x9AC4:0x4B8F or 0x502D:0x502D)
# 3. Query each for hw version
# 4. Create PM3Device objects
async def get_device(device_id: str) -> Optional[PM3Device]:
"""Get device by ID."""
async def get_available_devices() -> List[PM3Device]:
"""Get devices not currently in use."""
async def identify_device(device_id: str, duration_ms: int = 2000):
"""Blink LEDs on specific device for identification."""
# Send LED control command to device
async def update_device_status():
"""Refresh device list (handle hotplug)."""
Dependencies:
pyudevorpyserial.tools.list_portsfor USB enumerationglobfor /dev/ttyACM* discovery
1.2 Modify PM3Worker
Changes to: app/backend/workers/pm3_worker.py
- Remove hardcoded device path from constructor
- Add device metadata to worker
- Support device-specific initialization
class PM3Worker:
def __init__(self, device_path: str, device_id: str = None):
self.device_path = device_path
self.device_id = device_id or device_path
# ... existing code
Phase 2: Session Management Refactoring
2.1 Update Session Model
Changes to: app/backend/managers/session_manager.py
@dataclass
class Session:
session_id: str
device_id: str # NEW: Which PM3 device
client_ip: str
user_agent: Optional[str]
created_at: float
last_activity: float
class SessionManager:
"""Manages sessions across multiple devices."""
# Change from single session to dict of sessions per device
_active_sessions: Dict[str, Session] = {} # device_id -> Session
def has_active_session(self, device_id: str = None) -> bool:
"""Check if device has active session."""
if device_id is None:
# Any active session?
return len(self._active_sessions) > 0
return device_id in self._active_sessions
async def create_session(
self,
device_id: str, # NEW: Required
client_ip: str,
user_agent: Optional[str] = None,
force_takeover: bool = False
) -> tuple[bool, Optional[str], Optional[str]]:
"""Create session for specific device."""
def get_available_devices(
self,
all_devices: List[PM3Device]
) -> List[PM3Device]:
"""Filter devices without active sessions."""
return [d for d in all_devices
if d.device_id not in self._active_sessions]
Key Changes:
- Multi-device session tracking
- Device-specific session validation
- Available device filtering
2.2 Update Database Schema
Changes to: app/backend/models/database.py
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
device_id TEXT NOT NULL, -- NEW
device_path TEXT NOT NULL, -- NEW
client_ip TEXT,
user_agent TEXT,
created_at TIMESTAMP,
last_activity TIMESTAMP,
released_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS devices ( -- NEW TABLE
device_id TEXT PRIMARY KEY,
device_path TEXT NOT NULL,
serial_number TEXT,
friendly_name TEXT,
usb_vid TEXT,
usb_pid TEXT,
first_seen TIMESTAMP,
last_seen TIMESTAMP,
metadata TEXT -- JSON for firmware version, etc.
);
Phase 3: Service Layer Refactoring
3.1 Update PM3Service
Changes to: app/backend/services/pm3_service.py
class PM3Service:
"""PM3 service supporting multiple devices."""
def __init__(
self,
device_manager: PM3DeviceManager, # NEW
session_manager: SessionManager
):
self.device_manager = device_manager
self.session_manager = session_manager
# Remove single pm3_worker - now managed by device_manager
async def execute_command(
self,
command: str,
device_id: str, # NEW: Required
session_id: Optional[str] = None,
timeout: Optional[int] = None
) -> PM3ServiceResult:
"""Execute command on specific device."""
# 1. Validate session for this device
# 2. Get device from device_manager
# 3. Execute command via device's worker
async def get_status(
self,
device_id: str = None # NEW: Optional (all devices if None)
) -> PM3ServiceResult:
"""Get status for device(s)."""
if device_id is None:
# Return status for all devices
devices = await self.device_manager.discover_devices()
return PM3ServiceResult(
success=True,
data={
"devices": [
{
"device_id": d.device_id,
"device_path": d.device_path,
"friendly_name": d.friendly_name,
"connected": d.status == DeviceStatus.CONNECTED,
"in_use": self.session_manager.has_active_session(d.device_id),
"version": d.version
}
for d in devices
]
}
)
else:
# Return status for specific device
# ... existing single-device logic
async def identify_device(
self,
device_id: str,
duration_ms: int = 2000
) -> PM3ServiceResult:
"""Blink LEDs on device for identification."""
await self.device_manager.identify_device(device_id, duration_ms)
return PM3ServiceResult(success=True, data={"message": "Device identified"})
async def list_available_devices(self) -> PM3ServiceResult:
"""Get devices without active sessions."""
all_devices = await self.device_manager.discover_devices()
available = self.session_manager.get_available_devices(all_devices)
return PM3ServiceResult(
success=True,
data={"devices": [asdict(d) for d in available]}
)
3.2 Update ServiceContainer
Changes to: app/backend/services/container.py
class ServiceContainer:
def __init__(self):
# Create shared managers
self._device_manager = PM3DeviceManager() # NEW
self._session_manager = SessionManager()
self._wifi_manager = WiFiManager()
self._update_manager = get_update_manager()
# Create services with updated dependencies
self._pm3_service = PM3Service(
device_manager=self._device_manager, # NEW
session_manager=self._session_manager
)
# ... rest of services
@property
def device_manager(self) -> PM3DeviceManager: # NEW
"""Get device manager instance."""
return self._device_manager
Phase 4: API Endpoint Updates
4.1 New Device Endpoints
Changes to: app/backend/api/pm3.py
# NEW ENDPOINTS
@router.get("/devices", response_model=DevicesResponse)
async def list_devices():
"""List all detected Proxmark3 devices."""
result = await container.pm3_service.get_status()
# Returns list of all devices with status
@router.get("/devices/available", response_model=DevicesResponse)
async def list_available_devices():
"""List devices without active sessions."""
result = await container.pm3_service.list_available_devices()
@router.post("/devices/{device_id}/identify")
async def identify_device(device_id: str, duration: int = 2000):
"""Blink LEDs on device for identification."""
result = await container.pm3_service.identify_device(
device_id=device_id,
duration_ms=duration
)
@router.get("/devices/{device_id}/status")
async def get_device_status(device_id: str):
"""Get status for specific device."""
result = await container.pm3_service.get_status(device_id=device_id)
# UPDATED ENDPOINTS
@router.get("/status", response_model=StatusResponse)
async def get_status(device_id: str = None):
"""Get PM3 status (all devices or specific device)."""
result = await container.pm3_service.get_status(device_id=device_id)
@router.post("/command", response_model=CommandResponse)
async def execute_command(request: CommandRequest):
"""Execute PM3 command on specific device."""
# CommandRequest now includes device_id field
result = await container.pm3_service.execute_command(
command=request.command,
device_id=request.device_id, # NEW: Required
session_id=request.session_id
)
4.2 Update Request/Response Models
class CommandRequest(BaseModel):
command: str
device_id: str # NEW: Required
session_id: Optional[str] = None
class DeviceInfo(BaseModel):
device_id: str
device_path: str
friendly_name: str
serial_number: Optional[str]
connected: bool
in_use: bool
version: Optional[str]
session_id: Optional[str] # If device has active session
class DevicesResponse(BaseModel):
success: bool
devices: List[DeviceInfo]
class StatusResponse(BaseModel):
# For single device
connected: bool
device: DeviceInfo
# OR for all devices
devices: Optional[List[DeviceInfo]]
4.3 Update Session Endpoints
Changes to: app/backend/api/system.py
class CreateSessionRequest(BaseModel):
device_id: str # NEW: Required
force_takeover: bool = False
@router.post("/session/create", response_model=CreateSessionResponse)
async def create_session(request: Request, body: CreateSessionRequest):
"""Create session for specific device."""
# Updated to include device_id
@router.get("/session/{device_id}/status")
async def get_session_status(device_id: str):
"""Get session status for specific device."""
# NEW: Per-device session status
Phase 5: Frontend Refactoring
5.1 Device Selection Component
New File: app/frontend/app/components/DeviceSelector.tsx
interface DeviceSelectorProps {
selectedDeviceId: string | null;
onDeviceSelect: (deviceId: string) => void;
onIdentify: (deviceId: string) => void;
}
export default function DeviceSelector({
selectedDeviceId,
onDeviceSelect,
onIdentify
}: DeviceSelectorProps) {
const [devices, setDevices] = useState<Device[]>([]);
const [identifying, setIdentifying] = useState<string | null>(null);
// Fetch devices every 5 seconds
useEffect(() => {
const fetchDevices = async () => {
const response = await fetch('/api/pm3/devices');
const data = await response.json();
setDevices(data.devices);
};
fetchDevices();
const interval = setInterval(fetchDevices, 5000);
return () => clearInterval(interval);
}, []);
const handleIdentify = async (deviceId: string) => {
setIdentifying(deviceId);
await fetch(`/api/pm3/devices/${deviceId}/identify`, {
method: 'POST',
body: JSON.stringify({ duration: 2000 })
});
setTimeout(() => setIdentifying(null), 2100);
};
return (
<div className="card">
<h3 className="card-title">Select Proxmark3 Device</h3>
{devices.length === 0 && (
<div className="text-muted">
No Proxmark3 devices detected. Please connect a device.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{devices.map(device => (
<div
key={device.device_id}
className={`device-card ${selectedDeviceId === device.device_id ? 'selected' : ''}`}
style={{
padding: 'var(--space-3)',
border: '2px solid',
borderColor: selectedDeviceId === device.device_id
? 'var(--color-primary)'
: 'var(--color-border)',
borderRadius: 'var(--radius)',
cursor: device.in_use && selectedDeviceId !== device.device_id
? 'not-allowed'
: 'pointer',
opacity: device.in_use && selectedDeviceId !== device.device_id ? 0.5 : 1
}}
onClick={() => !device.in_use && onDeviceSelect(device.device_id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '1.1rem' }}>
{device.friendly_name || device.device_path}
</div>
<div style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>
{device.device_path}
{device.serial_number && ` • SN: ${device.serial_number}`}
</div>
<div style={{ fontSize: '0.75rem', marginTop: 'var(--space-1)' }}>
{device.connected ? (
<span style={{ color: 'var(--color-success)' }}>● Connected</span>
) : (
<span style={{ color: 'var(--color-error)' }}>● Disconnected</span>
)}
{device.in_use && (
<span style={{ marginLeft: 'var(--space-2)', color: 'var(--color-warning)' }}>
🔒 In Use
</span>
)}
{device.version && (
<span style={{ marginLeft: 'var(--space-2)', color: 'var(--color-text-secondary)' }}>
{device.version}
</span>
)}
</div>
</div>
<button
className="btn btn-secondary"
style={{ fontSize: '0.875rem' }}
onClick={(e) => {
e.stopPropagation();
handleIdentify(device.device_id);
onIdentify(device.device_id);
}}
disabled={!device.connected || identifying === device.device_id}
>
{identifying === device.device_id ? (
<>
<span className="spinner"></span>
<span>Blinking...</span>
</>
) : (
<>
<span>💡</span>
<span>Identify</span>
</>
)}
</button>
</div>
</div>
))}
</div>
</div>
);
}
5.2 Update Commands Page
Changes to: app/frontend/app/routes/commands.tsx
export default function Commands() {
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
// Create session when device is selected
useEffect(() => {
if (selectedDeviceId) {
async function createSession() {
const response = await fetch('/api/system/session/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: selectedDeviceId,
force_takeover: false
})
});
const data = await response.json();
if (data.success) {
setSessionId(data.session_id);
} else {
// Show conflict UI - offer takeover option
}
}
createSession();
}
}, [selectedDeviceId]);
return (
<div className="container">
<h1>PM3 Commands</h1>
{/* Device Selector */}
<DeviceSelector
selectedDeviceId={selectedDeviceId}
onDeviceSelect={setSelectedDeviceId}
onIdentify={(id) => console.log('Identifying', id)}
/>
{/* Command Interface (only shown when device selected) */}
{selectedDeviceId && sessionId && (
<>
{/* Existing command UI */}
<div className="card">
<h3 className="card-title">
Commands for {selectedDeviceId}
</h3>
{/* ... existing command interface ... */}
</div>
</>
)}
{selectedDeviceId && !sessionId && (
<div className="card" style={{ borderColor: 'var(--color-warning)' }}>
<h3>Session Conflict</h3>
<p>This device is currently in use by another session.</p>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<button className="btn btn-secondary" onClick={() => setSelectedDeviceId(null)}>
Choose Another Device
</button>
<button className="btn btn-primary" onClick={() => {/* Force takeover */}}>
Take Over Session
</button>
</div>
</div>
)}
</div>
);
}
5.3 Update Dashboard
Changes to: app/frontend/app/routes/_index.tsx
export default function Dashboard() {
const [devices, setDevices] = useState<Device[]>([]);
// Show all devices with quick status
return (
<div className="container">
<h1>Dashboard</h1>
<div className="card">
<h3 className="card-title">Proxmark3 Devices ({devices.length})</h3>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))' }}>
{devices.map(device => (
<div key={device.device_id} className="status-card">
<div className="status-indicator">
{device.connected ? '●' : '○'}
</div>
<div>
<strong>{device.friendly_name || device.device_path}</strong>
<div className="text-muted">{device.version}</div>
<div style={{ marginTop: 'var(--space-2)' }}>
{device.in_use ? (
<span className="badge badge-warning">In Use</span>
) : (
<span className="badge badge-success">Available</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* ... rest of dashboard ... */}
</div>
);
}
Phase 6: BLE GATT Updates
6.1 Update BLE Characteristics
Changes to: app/backend/ble/characteristics.py
class PM3CharacteristicUUIDs:
"""UUID constants for PM3 GATT characteristics."""
# Existing UUIDs
COMMAND_WRITE = "00002a01-0000-1000-8000-00805f9b34fb"
COMMAND_RESULT = "00002a02-0000-1000-8000-00805f9b34fb"
STATUS = "00002a03-0000-1000-8000-00805f9b34fb"
# NEW UUIDs for multi-device support
DEVICES_LIST = "00002a10-0000-1000-8000-00805f9b34fb" # NEW
DEVICE_SELECT = "00002a11-0000-1000-8000-00805f9b34fb" # NEW
DEVICE_IDENTIFY = "00002a12-0000-1000-8000-00805f9b34fb" # NEW
6.2 Update GATT Server Handlers
Changes to: app/backend/ble/gatt_server.py
def _register_pm3_characteristics(self):
"""Register PM3 GATT characteristics."""
self._characteristic_handlers.update({
# Existing characteristics...
# NEW: Device management characteristics
PM3CharacteristicUUIDs.DEVICES_LIST: CharacteristicHandler(
uuid=PM3CharacteristicUUIDs.DEVICES_LIST,
properties=["read", "notify"],
read_handler=self._handle_devices_list_read,
description="List all PM3 devices"
),
PM3CharacteristicUUIDs.DEVICE_SELECT: CharacteristicHandler(
uuid=PM3CharacteristicUUIDs.DEVICE_SELECT,
properties=["write"],
write_handler=self._handle_device_select_write,
description="Select PM3 device for session"
),
PM3CharacteristicUUIDs.DEVICE_IDENTIFY: CharacteristicHandler(
uuid=PM3CharacteristicUUIDs.DEVICE_IDENTIFY,
properties=["write"],
write_handler=self._handle_device_identify_write,
description="Identify PM3 device (blink LEDs)"
),
})
async def _handle_devices_list_read(self) -> bytes:
"""Handle device list read via BLE."""
result = await container.pm3_service.get_status()
if result.success:
return json.dumps(result.data).encode('utf-8')
# ... error handling
async def _handle_device_identify_write(self, value: bytes) -> Dict[str, Any]:
"""Handle device identification via BLE."""
data = json.loads(value.decode('utf-8'))
device_id = data.get("device_id")
duration = data.get("duration_ms", 2000)
result = await container.pm3_service.identify_device(
device_id=device_id,
duration_ms=duration
)
return {"success": result.success}
async def _handle_pm3_command_write(self, value: bytes) -> Dict[str, Any]:
"""Handle PM3 command execution via BLE."""
data = json.loads(value.decode('utf-8'))
command = data.get("command")
device_id = data.get("device_id") # NEW: Required
session_id = data.get("session_id")
result = await container.pm3_service.execute_command(
command=command,
device_id=device_id, # NEW: Required
session_id=session_id
)
# ... rest of handler
Phase 7: LED Identification Implementation
7.1 LED Control Command Implementation
Option A: Custom PM3 Client Command (Preferred)
Create wrapper script: app/backend/utils/pm3_led_control.py
"""LED control utilities for Proxmark3."""
import asyncio
from typing import Optional
# LED bit masks (from armsrc/util.h)
LED_RED = 1 # LED A
LED_ORANGE = 2 # LED B
LED_GREEN = 4 # LED C
LED_RED2 = 8 # LED D
LED_ALL = 15 # All LEDs
class PM3LEDController:
"""Control Proxmark3 LEDs for device identification."""
@staticmethod
async def blink_pattern(
device_path: str,
pattern: str = "all",
duration_ms: int = 2000,
blink_count: int = 3
) -> bool:
"""Blink LEDs in a specific pattern.
Args:
device_path: Device path (e.g., /dev/ttyACM0)
pattern: "all", "alternating", "chase", or custom LED mask
duration_ms: Total duration in milliseconds
blink_count: Number of blinks
Returns:
True if successful
"""
try:
# Import pm3 module
import pm3
# Open device
device = await asyncio.get_event_loop().run_in_executor(
None,
pm3.open,
device_path
)
if pattern == "all":
await cls._blink_all(device, duration_ms, blink_count)
elif pattern == "alternating":
await cls._blink_alternating(device, duration_ms, blink_count)
elif pattern == "chase":
await cls._blink_chase(device, duration_ms, blink_count)
else:
# Custom pattern
await cls._blink_custom(device, int(pattern), duration_ms, blink_count)
return True
except Exception as e:
print(f"LED control error: {e}")
return False
@staticmethod
async def _blink_all(device, duration_ms: int, count: int):
"""Blink all LEDs."""
interval_ms = duration_ms // (count * 2)
for _ in range(count):
# Turn on all LEDs using hw tune (generates LED activity)
# OR if hw led command is available:
# await device.cmd(f"hw led --set {LED_ALL} --duration {interval_ms}")
# Workaround: Use hw tune which briefly activates LEDs
await asyncio.get_event_loop().run_in_executor(
None,
device.cmd,
"hw tune --lf --duration 50" # Brief LF tune
)
await asyncio.sleep(interval_ms / 1000)
# LEDs off (wait)
await asyncio.sleep(interval_ms / 1000)
@staticmethod
async def _blink_alternating(device, duration_ms: int, count: int):
"""Blink LEDs in alternating pattern (A/C then B/D)."""
# Implementation similar to _blink_all but with alternating LEDs
pass
@staticmethod
async def _blink_chase(device, duration_ms: int, count: int):
"""Chase LEDs A -> B -> C -> D."""
# Implementation with sequential LED activation
pass
Option B: Firmware Modification
If implementing custom hw led command in PM3 firmware:
- Add command to
client/src/cmdhw.c:
static int CmdHwLed(const char *Cmd) {
uint8_t led_mask = 0;
uint16_duration_ms = 1000;
// Parse arguments
// ...
// Send LED command to device
SendCommandNG(CMD_LED_CONTROL, &led_mask, sizeof(led_mask));
return PM3_SUCCESS;
}
- Add handler to
armsrc/appmain.c - Rebuild PM3 client and firmware
- Flash firmware to devices
7.2 Integration with Device Manager
# In PM3DeviceManager
async def identify_device(self, device_id: str, duration_ms: int = 2000):
"""Blink LEDs on device for identification."""
device = await self.get_device(device_id)
if not device:
raise ValueError(f"Device {device_id} not found")
# Use LED controller
success = await PM3LEDController.blink_pattern(
device_path=device.device_path,
pattern="all", # Or "chase" for cooler effect
duration_ms=duration_ms,
blink_count=3
)
if not success:
raise RuntimeError(f"Failed to identify device {device_id}")
Implementation Timeline
Sprint 1: Foundation (Week 1)
- Create
PM3DeviceManagerclass - Implement USB device enumeration
- Add device detection tests
- Update database schema
Sprint 2: Core Refactoring (Week 2)
- Refactor
SessionManagerfor multi-device - Update
PM3Servicewith device_id parameter - Update
ServiceContainerwith new dependencies - Write migration script for existing sessions
Sprint 3: API Updates (Week 3)
- Add new device endpoints
- Update existing endpoints with device_id
- Update request/response models
- API integration tests
Sprint 4: Frontend (Week 4)
- Create
DeviceSelectorcomponent - Update Commands page
- Update Dashboard
- Add session conflict UI
Sprint 5: BLE Integration (Week 5)
- Add BLE device characteristics
- Update GATT server handlers
- BLE integration tests
Sprint 6: LED Identification (Week 6)
- Implement LED control (Option A or B)
- Test LED patterns on hardware
- Integrate with device manager
- Polish UX
Sprint 7: Testing & Polish (Week 7)
- End-to-end testing with multiple devices
- Performance testing
- Bug fixes
- Documentation updates
Testing Strategy
Unit Tests
# tests/unit/managers/test_pm3_device_manager.py
@pytest.mark.asyncio
async def test_discover_devices():
"""Test device discovery."""
manager = PM3DeviceManager()
devices = await manager.discover_devices()
assert isinstance(devices, list)
# More assertions...
@pytest.mark.asyncio
async def test_device_identification():
"""Test LED identification."""
manager = PM3DeviceManager()
# Mock device
await manager.identify_device("test-device-id", duration_ms=1000)
# Assert LED control was called
Integration Tests
# tests/integration/test_multi_device.py
@pytest.mark.asyncio
async def test_multiple_devices_session_isolation():
"""Test that sessions are isolated per device."""
# Create session on device 1
session1 = await create_session(device_id="dev1")
# Create session on device 2 (should succeed)
session2 = await create_session(device_id="dev2")
assert session1.session_id != session2.session_id
assert session1.device_id == "dev1"
assert session2.device_id == "dev2"
@pytest.mark.asyncio
async def test_device_session_conflict():
"""Test session conflict on same device."""
# Create session on device 1
session1 = await create_session(device_id="dev1")
# Try to create another session on device 1 (should fail)
with pytest.raises(SessionConflictError):
await create_session(device_id="dev1", force_takeover=False)
# With force_takeover (should succeed)
session2 = await create_session(device_id="dev1", force_takeover=True)
assert session2.session_id != session1.session_id
Hardware Tests
# tests/hardware/test_led_control.py
@pytest.mark.hardware
@pytest.mark.asyncio
async def test_led_blink_all():
"""Test LED blinking on real hardware."""
controller = PM3LEDController()
success = await controller.blink_pattern(
device_path="/dev/ttyACM0",
pattern="all",
duration_ms=2000,
blink_count=3
)
assert success
Migration Strategy
Backward Compatibility
Phase 1: Dual Mode
- Support both old API (single device) and new API (multi-device)
- Old endpoints default to first available device
- Add deprecation warnings
# Old endpoint (deprecated but functional)
@router.post("/command") # No device_id required
async def execute_command_legacy(request: CommandRequest):
"""DEPRECATED: Use /devices/{device_id}/command instead."""
# Get first available device
devices = await container.device_manager.discover_devices()
if not devices:
raise HTTPException(404, "No devices found")
# Use first device
device_id = devices[0].device_id
# Call new implementation
return await execute_command_v2(
device_id=device_id,
request=request
)
Phase 2: Migration
- Update frontend to use new API
- Provide migration guide
- Update documentation
Phase 3: Deprecation
- Remove old endpoints
- Clean up backward compatibility code
Data Migration
# scripts/migrate_sessions_to_multidevice.py
async def migrate_sessions():
"""Migrate existing sessions to multi-device format."""
# 1. Get current device (assume /dev/ttyACM0)
default_device_id = "default-pm3"
# 2. Update all sessions in database
async with database.get_connection() as conn:
await conn.execute("""
UPDATE sessions
SET device_id = ?,
device_path = '/dev/ttyACM0'
WHERE device_id IS NULL
""", (default_device_id,))
# 3. Create device entry
await device_manager.register_device(
device_id=default_device_id,
device_path="/dev/ttyACM0"
)
Configuration Changes
Environment Variables
New Variables:
# .env
# Device Management
PM3_AUTO_DISCOVER=true # Auto-discover devices on startup
PM3_DISCOVERY_INTERVAL=30 # Re-scan interval (seconds)
PM3_DEVICE_TIMEOUT=300 # Mark device as disconnected after N seconds
# LED Identification
PM3_LED_PATTERN=all # Default LED pattern (all, chase, alternating)
PM3_LED_DURATION=2000 # Default LED blink duration (ms)
PM3_LED_BLINK_COUNT=3 # Number of blinks
# Session Management (per device)
SESSION_TIMEOUT=300 # Session timeout (seconds) - unchanged
MAX_SESSIONS_PER_DEVICE=1 # Max concurrent sessions per device
Config File
# app/backend/config.py
# Device Management
PM3_AUTO_DISCOVER = os.getenv("PM3_AUTO_DISCOVER", "true").lower() == "true"
PM3_DISCOVERY_INTERVAL = int(os.getenv("PM3_DISCOVERY_INTERVAL", "30"))
PM3_DEVICE_TIMEOUT = int(os.getenv("PM3_DEVICE_TIMEOUT", "300"))
# USB Identification
PM3_USB_VID_PRIMARY = "0x9AC4" # Standard PM3
PM3_USB_PID_PRIMARY = "0x4B8F"
PM3_USB_VID_EASY = "0x502D" # PM3 Easy
PM3_USB_PID_EASY = "0x502D"
# LED Control
PM3_LED_PATTERN = os.getenv("PM3_LED_PATTERN", "all")
PM3_LED_DURATION = int(os.getenv("PM3_LED_DURATION", "2000"))
PM3_LED_BLINK_COUNT = int(os.getenv("PM3_LED_BLINK_COUNT", "3"))
Dependencies
New Python Packages
# requirements.txt
# Existing dependencies...
# NEW: For USB device enumeration
pyudev>=0.24.0 # Linux udev bindings for device detection
pyserial>=3.5 # Serial port enumeration (cross-platform)
Optional Dependencies
# requirements-led.txt (if building custom LED control)
# For custom firmware compilation (if needed)
# arm-none-eabi-gcc (system package, not pip)
Documentation Updates
New Documentation Files
-
MULTI_DEVICE_GUIDE.md- How to connect multiple PM3 devices
- USB hub recommendations
- Device identification workflow
- Troubleshooting
-
LED_IDENTIFICATION.md- LED control technical details
- Firmware modification guide (if applicable)
- Custom patterns guide
-
API_MIGRATION.md- Breaking changes
- Migration from v1 to v2 API
- Code examples
Updated Documentation
-
README.md- Multi-device support feature
- Updated architecture diagram
-
PROJECT_STATUS.md- Multi-device refactoring status
-
API.md(if exists, or create)- Complete API reference with device_id parameter
Risks & Mitigation
Risk 1: LED Control Not Working on PM3 Easy
Likelihood: Medium Impact: Medium
Mitigation:
- Implement fallback: Use existing commands (hw tune) that trigger LED activity
- Alternative: Physical labeling system (stickers with QR codes)
- Last resort: Manual device selection by path
Risk 2: USB Device Enumeration Issues
Likelihood: Low Impact: High
Mitigation:
- Test on multiple Linux distributions
- Provide manual device configuration option
- Extensive error handling and logging
- Document udev rules if needed
Risk 3: Session Management Complexity
Likelihood: Medium Impact: Medium
Mitigation:
- Comprehensive unit tests
- Clear session conflict UI
- Session debugging tools
- Fallback to single-device mode
Risk 4: Performance with Many Devices
Likelihood: Low Impact: Low
Mitigation:
- Efficient device polling (only when needed)
- Caching device status
- Async operations throughout
- Performance testing with 10+ devices
Success Criteria
Must Have (MVP)
- ✅ Detect and list N Proxmark3 devices
- ✅ Create sessions per device
- ✅ Execute commands on specific device
- ✅ Basic device selection UI
- ✅ Device identification (LED or alternative method)
- ✅ Session conflict resolution
- ✅ BLE support for multi-device
Should Have
- ✅ Friendly device names
- ✅ Device discovery polling
- ✅ LED blink patterns (if firmware supports)
- ✅ Available vs. in-use device indication
- ✅ Auto-select device if only one available
Nice to Have
- ⭐ Device persistence (remember friendly names) -- yes
- ⭐ Device statistics (usage time, command count) -- oh, cool, sure
- ⭐ Device grouping/tagging -- ok
- ⭐ Advanced LED patterns (custom sequences)
- ⭐ Device health monitoring -- I am curious as to what this would entail -- elaborate?
Decisions & Implementation Notes
Core Features - DECIDED ✅
-
LED Control Implementation: ✅ DECIDED
- Decision: Modify PM3 firmware to add
hw ledcommand - Notes:
- Document current firmware version and lock to it until MVP complete
- Start with workaround (hw tune) for immediate testing
- Contribute
hw ledcommand upstream to RRG/Iceman fork after testing
- Implementation: Sprint 6
- Decision: Modify PM3 firmware to add
-
Device Naming: ✅ DECIDED
- Decision: Auto-generate using interface name + allow user customization
- Default naming: Use USB interface name (e.g., "ttyACM0", "ttyACM1")
- Fallback naming: "PM3-1", "PM3-2" if interface name unavailable
- User customization: Allow setting friendly name in UI
- Edge case: Handle "no PM3s connected" state gracefully with helpful messaging
- Implementation: Sprint 4
-
Session Persistence: ✅ DECIDED
- Decision: Do NOT persist sessions across server restarts
- Storage: Memory only
- Behavior: All sessions invalidated on server restart
- Implementation: Sprint 2
-
Device Hotplug: ✅ DECIDED
- Decision: Use OS-level device detection (udev events) instead of polling
- Notification: Notify users via SSE/BLE when devices connect/disconnect
- Fallback: Polling as backup if udev unavailable
- Implementation: Sprint 1
Firmware Management - DECIDED ✅
-
Firmware Source Strategy: ✅ DECIDED
- Decision: Multi-source approach
- Priority order:
- Bundled firmware (PRIMARY) - crucial for guided tools and project stability
- System-installed firmware (/usr/share/proxmark3/)
- Download from Dangerous Pi project releases (not upstream PM3 repo)
- Rationale: Project stability paramount to usability
- Bundled version: Lock to specific tested version
- Implementation: Sprint 6
-
Firmware Version Tolerance: ✅ DECIDED
- Decision: STRICT - Exact version match required
- Policy: Firmware must exactly match expected version
- No tolerance: No version mismatches allowed
- Behavior: Devices with mismatched firmware shown as disabled until updated
- Implementation: Sprint 2
-
Bootloader Flashing Policy: ✅ DECIDED
- Decision: Allow with safety checks
- Requirements:
- Strong warnings and multiple confirmations
- Power source check: Must be plugged into AC power
- Battery check: If on battery, must be ≥80% charge
- Battery capacity: Target 2500-5000mAh
- Safety: Prevent flashing if conditions not met
- Recovery: Provide JTAG recovery documentation
- Implementation: Sprint 6
-
Automatic Firmware Updates: ✅ DECIDED
- Decision: Auto-update devices when Dangerous Pi system updates
- Trigger: System upgrade process
- Behavior: Flash all connected PM3s to bundled firmware version
- User control: Prompt with option to skip
- Implementation: Sprint 5
-
Version Mismatch "Ignore" Behavior: ✅ DECIDED
- Decision: No persistence, strict enforcement
- Ignore behavior: Does NOT persist across sessions
- Re-prompt: Always re-prompt if firmware version changes
- Re-enable: Devices only become enabled when firmware matches
- No workaround: Users must update firmware to use device
- Implementation: Sprint 5
Hardware & Infrastructure - DECIDED ✅
-
USB Hub Power: ✅ DECIDED
- Decision: Active monitoring and warnings
- Threshold: Warn when >2 PM3 devices connected
- Recommendation: Document powered USB hub requirement
- Detection: Attempt to detect unpowered hubs (voltage monitoring)
- Warning UI: Show power consumption estimates
- Implementation: Documentation + Sprint 3
-
Simultaneous Flashing: ✅ DECIDED
- Decision: Dynamic parallel flashing based on power/load
- When plugged in: Allow parallel flashing
- When on battery: Dynamic limiting based on battery level and load
- Algorithm: Calculate safe parallel count based on:
- Current battery level
- Estimated flash power consumption per device
- USB bus bandwidth availability
- System load
- Safety margins: Conservative estimates to prevent issues
- Implementation: Sprint 6
-
Bootloader Mode Detection: ✅ DECIDED
- Decision: Use PM3 client's firmware verification techniques
- Method: Same detection approach as proxmark3 client
- Checks: USB descriptor + firmware response validation
- Fallback: Manual user override option
- Implementation: Sprint 6
New Implementation Questions
Battery & Power Management
-
Battery Level Monitoring for Firmware Operations:
- How to accurately read battery level during flash operations?
- Should we prevent starting new flashes if battery drops below threshold mid-operation?
- What's the safe battery drain rate during multi-device flashing?
- Decision needed by: Sprint 6
- Recommendation: Use UPS manager battery readings, halt new flashes at 75%, continue in-progress
-
Dynamic Flash Concurrency Algorithm:
- How many devices can flash simultaneously on battery vs. AC?
- Power consumption per PM3 during flash?
- How to estimate remaining battery capacity during operation?
- Decision needed by: Sprint 6
- Recommendation:
- AC power: Up to 4 concurrent flashes
- Battery 50-100%: 1 at a time
- Battery <50%: Warn and recommend AC
Device Detection & Identification
-
Udev Event Implementation:
- Use pyudev library or direct udev monitoring?
- How to handle udev permissions (requires udev rules)?
- Fallback gracefully on systems without udev?
- Decision needed by: Sprint 1
- Recommendation: Use pyudev with polling fallback, provide udev rules in install
-
Interface Name Extraction:
- Parse interface name from /dev/ttyACM* path?
- Store interface name in device database?
- Handle interface changes on reconnection?
- Decision needed by: Sprint 4
- Recommendation: Extract from path, store in DB, match by serial if interface changes
Bundled Firmware Management
-
Bundled Firmware Versioning:
- How to version bundled firmware files?
- Where to store in project structure?
- How to handle firmware updates in Dangerous Pi releases?
- Decision needed by: Sprint 6
- Recommendation:
dangerous-pi/ firmware/ version.txt # Current bundled version fullimage.elf bootrom.elf FIRMWARE_VERSION.md # Changelog
-
Firmware Compatibility Matrix:
- Document which Dangerous Pi version works with which PM3 firmware?
- Prevent downgrades that break features?
- Decision needed by: Sprint 6
- Recommendation: Maintain compatibility matrix in docs, warn on downgrades
Edge Cases
-
No PM3 Devices Connected:
- What should UI show?
- Prevent errors in device enumeration?
- Helpful onboarding messages?
- Decision needed by: Sprint 4
- Recommendation: Friendly empty state with "Connect a Proxmark3 to get started"
-
All Devices Disabled (Version Mismatch):
- Show "Update All" button prominently?
- Explain why devices are disabled?
- Streamline bulk update flow?
- Decision needed by: Sprint 5
- Recommendation: Large "Update All Devices" CTA, explain version requirements
Summary of Additional Considerations
Beyond the core multi-device refactoring, this plan addresses:
Firmware & Version Management
- ✅ Automatic firmware version detection
- ✅ Version compatibility checking (semantic versioning)
- ✅ Firmware flashing integration
- ✅ Bootloader safety mechanisms
- ✅ Version mismatch handling (flash or ignore)
- ✅ Batch device updates
- ✅ Flash progress tracking
- ✅ Firmware update audit logs
Device Identification
- ✅ LED blinking patterns for identification
- ✅ Multiple identification modes (all, chase, alternating)
- ✅ Works in both WiFi and BLE modes
- ✅ Visual feedback in UI during identification
Safety & Recovery
- ✅ Bootloader preservation by default
- ✅ Flash verification
- ✅ Rollback on failure
- ✅ JTAG recovery documentation
- ✅ Strong warnings for dangerous operations
User Experience
- ✅ Clear version mismatch warnings
- ✅ One-click firmware updates
- ✅ Batch "Update All" operation
- ✅ Real-time flash progress
- ✅ Device status indicators
- ✅ BLE firmware notifications
Performance & Reliability
- ✅ Efficient device polling
- ✅ Async flash operations
- ✅ USB bus load management
- ✅ Device hotplug detection
- ✅ Connection status monitoring
Conclusion
This refactoring will transform Dangerous Pi from a single-device tool into a scalable multi-device platform suitable for labs, workshops, hackerspaces, and power users. The phased approach ensures backward compatibility while systematically updating all layers of the application.
Key Implementation Decisions
Firmware Management:
- ✅ Strict version enforcement: No tolerance for mismatched firmware
- ✅ Bundled firmware primary: Ships with tested, locked firmware version
- ✅ Auto-update on system upgrade: Keeps fleet consistent
- ✅ Battery safety: 80% minimum for bootloader flashing
- ✅ Dynamic parallel flashing: Based on power availability
Device Management:
- ✅ Udev-based detection: Real-time device hotplug notifications
- ✅ Interface-based naming: Uses ttyACM0, ttyACM1, etc.
- ✅ LED identification: Custom firmware command for physical ID
- ✅ Session isolation: Per-device sessions, no persistence across restarts
User Experience:
- ✅ Zero-config device detection: Works out of the box
- ✅ Graceful degradation: Helpful messaging when no devices connected
- ✅ Power awareness: Warnings and dynamic behavior based on battery/AC
- ✅ Bulk operations: Update all devices with one click
Updated Estimates
Estimated Effort: 9-10 weeks (updated from 8-9 weeks)
- Sprint 1: Device enumeration & udev (1.5 weeks)
- Sprint 2: Session management refactoring (1.5 weeks)
- Sprint 3: Service layer & API updates (2 weeks)
- Sprint 4: Frontend implementation (1.5 weeks)
- Sprint 5: BLE integration (1 week)
- Sprint 6: Firmware management & LED control (2 weeks)
- Sprint 7: Testing, polish, battery safety (1 week)
Additional scope from user decisions:
- Udev integration (+0.5 weeks)
- Battery monitoring & dynamic flashing (+0.5 weeks)
- Bundled firmware packaging (+0.5 weeks)
- Strict version enforcement logic (+0.5 weeks)
Complexity: Very High Value: Extremely High Risk: Medium (mitigated by phased approach and testing)
Hardware Requirements for Testing
Minimum:
- 2-3 Proxmark3 Easy devices (different firmware versions)
- Powered USB 3.0 hub (7+ ports, 2A+ per port)
- Raspberry Pi Zero 2 W
- UPS HAT with 2500-5000mAh battery
Recommended:
- 5+ Proxmark3 Easy devices for stress testing
- Mix of firmware versions (intentional mismatches)
- Power meter for USB bus load monitoring
- Optional: Device with bricked firmware for recovery testing
- Optional: Different USB hubs for compatibility testing
Next Steps - Ready to Begin!
Phase 1: Foundation (Sprint 1 - Week 1-1.5)
- ✅ Plan approved with user decisions
- ⏭ START HERE: Implement PM3DeviceManager with udev integration
- ⏭ Document current PM3 firmware version (lock for MVP)
- ⏭ Set up udev rules for device detection
- ⏭ Implement device enumeration tests
Phase 2: Core Architecture (Sprints 2-3 - Weeks 2-5) 6. ⏭ Refactor SessionManager for multi-device 7. ⏭ Update database schema with device & firmware tables 8. ⏭ Implement strict firmware version checking 9. ⏭ Update PM3Service and ServiceContainer 10. ⏭ Create new API endpoints for devices
Phase 3: User Interface (Sprint 4 - Weeks 6-7) 11. ⏭ Build DeviceSelector component with no-devices empty state 12. ⏭ Implement firmware mismatch warnings 13. ⏭ Add battery level indicators 14. ⏭ Create FirmwareFlashDialog with progress tracking
Phase 4: Advanced Features (Sprints 5-6 - Weeks 8-10) 15. ⏭ BLE multi-device support 16. ⏭ Bundle firmware files in project 17. ⏭ Implement LED control (firmware mod + fallback) 18. ⏭ Dynamic parallel flashing algorithm 19. ⏭ Battery safety checks for flashing
Phase 5: Testing & Polish (Sprint 7 - Week 10) 20. ⏭ End-to-end testing with multiple devices 21. ⏭ Battery/power testing scenarios 22. ⏭ Firmware mismatch scenarios 23. ⏭ Documentation updates 24. ⏭ Performance optimization
Critical Path Items:
- 🔴 Udev integration (blocker for real-time detection)
- 🔴 Bundled firmware packaging (blocker for version enforcement)
- 🔴 Battery monitoring (blocker for safe flashing)
- 🟡 LED control firmware mod (nice-to-have, has workaround)
Success Criteria:
- ✅ Support 5+ concurrent PM3 devices
- ✅ Real-time device hotplug detection
- ✅ Zero tolerance for firmware mismatches
- ✅ Safe firmware flashing with battery checks
- ✅ LED identification working on all devices
- ✅ Graceful handling of zero devices
- ✅ BLE support for multi-device
- ✅ <2 second device switching latency
References
Web Resources
LED Control:
- Proxmark3 RDV4 LEDs Discussion - LED control discussion
- Proxmark3 armsrc/util.h - LED control functions (LEDson, LEDsoff, LED, LEDsinvert)
- Proxmark3 Standalone Mode - LED usage examples
Firmware & Flashing:
- Proxmark3 Flashing Guide - Official flashing documentation
- Proxmark3 Troubleshooting - Bootloader and flashing issues
- Proxmark3 Bootloader Fix Guide - Bootloader recovery
- RfidResearchGroup Proxmark3 - Iceman fork (PM3 Easy firmware source)
Hardware & Device Info:
- Proxmark3 Commands Wiki - Command reference
- Proxmark3 Easy Specs - Hardware specifications
- Proxmark3 Easy Product Page - Dangerous Things official page
- USB Serial Number Issues - Device identification challenges
- Proxmark3 PM3 Easy LED Indicators - LED meanings
Internal Documentation
PROJECT_STATUS.md- Current project statusclaude.md- Development guidelinesUI_GUIDELINES.md- Frontend design principlesapp/backend/services/pm3_service.py- Current PM3 service implementationapp/backend/managers/session_manager.py- Current session management
Document Version: 2.0 Last Updated: 2025-11-26 Author: AI Planning Agent (with user requirements input) Status: Approved - Ready for Implementation
Changelog:
- v2.0 (2025-11-26): User decisions incorporated, new implementation questions added
- Converted open questions to decided items
- Added strict firmware version policy
- Added battery/power management requirements
- Added udev-based device detection
- Added bundled firmware as primary source
- Added 20 new implementation questions
- v1.1 (2025-11-26): Added comprehensive firmware version management section
- v1.0 (2025-11-26): Initial multi-device refactoring plan