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