Files
pi-pm3/docs/archive/MULTI_PM3_REFACTORING_PLAN.md
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

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/ttyACM0 in config
  • Single Worker: One PM3Worker instance 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

  1. PM3Worker (app/backend/workers/pm3_worker.py)
  2. PM3Service (app/backend/services/pm3_service.py)
  3. SessionManager (app/backend/managers/session_manager.py)
  4. ServiceContainer (app/backend/services/container.py)
  5. Frontend UI (all routes)
  6. BLE GATT Server (app/backend/ble/gatt_server.py)
  7. 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 led command 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 led command 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 tune briefly 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:

  1. Query hw version command
  2. Parse firmware version, bootloader version, client version
  3. Compare against local PM3 client version
  4. 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, selectable
  • VERSION_MISMATCH: Yellow indicator, show warning banner, offer flash
  • DISABLED: Gray indicator, show "Update firmware to enable" button
  • FLASHING: Blue indicator with progress bar, not selectable
  • BOOTLOADER_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:

  1. Backup before flash (if possible)
  2. JTAG recovery instructions for worst case
  3. Bootloader preservation - avoid flashing bootrom unless necessary
  4. 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:

  • pyudev or pyserial.tools.list_ports for USB enumeration
  • glob for /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:

  1. 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;
}
  1. Add handler to armsrc/appmain.c
  2. Rebuild PM3 client and firmware
  3. 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 PM3DeviceManager class
  • Implement USB device enumeration
  • Add device detection tests
  • Update database schema

Sprint 2: Core Refactoring (Week 2)

  • Refactor SessionManager for multi-device
  • Update PM3Service with device_id parameter
  • Update ServiceContainer with 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 DeviceSelector component
  • 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

  1. MULTI_DEVICE_GUIDE.md

    • How to connect multiple PM3 devices
    • USB hub recommendations
    • Device identification workflow
    • Troubleshooting
  2. LED_IDENTIFICATION.md

    • LED control technical details
    • Firmware modification guide (if applicable)
    • Custom patterns guide
  3. API_MIGRATION.md

    • Breaking changes
    • Migration from v1 to v2 API
    • Code examples

Updated Documentation

  1. README.md

    • Multi-device support feature
    • Updated architecture diagram
  2. PROJECT_STATUS.md

    • Multi-device refactoring status
  3. 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

  1. LED Control Implementation: DECIDED

    • Decision: Modify PM3 firmware to add hw led command
    • Notes:
      • Document current firmware version and lock to it until MVP complete
      • Start with workaround (hw tune) for immediate testing
      • Contribute hw led command upstream to RRG/Iceman fork after testing
    • Implementation: Sprint 6
  2. 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
  3. Session Persistence: DECIDED

    • Decision: Do NOT persist sessions across server restarts
    • Storage: Memory only
    • Behavior: All sessions invalidated on server restart
    • Implementation: Sprint 2
  4. 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

  1. Firmware Source Strategy: DECIDED

    • Decision: Multi-source approach
    • Priority order:
      1. Bundled firmware (PRIMARY) - crucial for guided tools and project stability
      2. System-installed firmware (/usr/share/proxmark3/)
      3. 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
  2. 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
  3. 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
  4. 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
  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

  1. 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
  2. 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
  3. 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

  1. 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
  2. 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

  1. 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
  2. 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

  1. 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
      
  2. 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

  1. 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"
  2. 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)

  1. Plan approved with user decisions
  2. START HERE: Implement PM3DeviceManager with udev integration
  3. ⏭ Document current PM3 firmware version (lock for MVP)
  4. ⏭ Set up udev rules for device detection
  5. ⏭ 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:

Firmware & Flashing:

Hardware & Device Info:

Internal Documentation

  • PROJECT_STATUS.md - Current project status
  • claude.md - Development guidelines
  • UI_GUIDELINES.md - Frontend design principles
  • app/backend/services/pm3_service.py - Current PM3 service implementation
  • app/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