#!/usr/bin/env python3 """ Unified udev rules generator for ESP32 devices. Combines functionality from gen_udev_rules.py and append_new_rules.py. Features: - Generate rules from scratch (full scan) - Append new rules to existing file (incremental update) - Uses ID_PATH for stable device identification - Sorts devices by physical topology for consistent ordering """ import os import re import sys import subprocess from pathlib import Path from serial.tools import list_ports class Colors: GREEN = '\033[92m' RED = '\033[91m' YELLOW = '\033[93m' BLUE = '\033[94m' CYAN = '\033[96m' RESET = '\033[0m' # Default paths DEFAULT_RULES_FILE = "/etc/udev/rules.d/99-esp32-stable.rules" TEMP_RULES_FILE = "new_rules.part" def get_existing_rules(rules_path): """ Parse existing rules file to extract: - Set of ID_PATH values already mapped - Maximum port number used Returns: (known_paths, max_port) """ known_paths = set() max_port = 0 if not os.path.exists(rules_path): return known_paths, 0 regex_path = re.compile(r'ENV\{ID_PATH\}=="([^"]+)"') regex_port = re.compile(r'esp_port_(\d+)') try: with open(rules_path, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue path_match = regex_path.search(line) port_match = regex_port.search(line) if path_match: known_paths.add(path_match.group(1)) if port_match: port_num = int(port_match.group(1)) if port_num > max_port: max_port = port_num except Exception as e: print(f"{Colors.RED}Error reading rules file: {e}{Colors.RESET}") return known_paths, 0 return known_paths, max_port def scan_usb_devices(): """ Scan all USB serial devices and return list of (device, id_path, location) tuples. Uses udevadm to get stable ID_PATH identifiers. """ devices = [] # Get all USB serial devices usb_devices = list(list_ports.grep("USB|ACM|CP210|FT232")) if not usb_devices: return devices # Sort by physical location for consistent ordering usb_devices.sort(key=lambda x: x.location if x.location else x.device) for dev in usb_devices: try: # Get ID_PATH via udevadm (most stable identifier) cmd = ['udevadm', 'info', '--name', dev.device, '--query=property'] proc = subprocess.run(cmd, capture_output=True, text=True, timeout=2) if proc.returncode != 0: continue props = dict(line.split('=', 1) for line in proc.stdout.splitlines() if '=' in line) id_path = props.get('ID_PATH', '') if not id_path: continue devices.append({ 'device': dev.device, 'id_path': id_path, 'location': dev.location or '', 'serial': props.get('ID_SERIAL_SHORT', '') }) except Exception as e: print(f"{Colors.YELLOW}Warning: Could not inspect {dev.device}: {e}{Colors.RESET}") continue return devices def generate_rules(devices, start_port=1): """ Generate udev rules for the given devices. Returns list of rule strings. """ rules = [] port_num = start_port for dev in devices: symlink = f"esp_port_{port_num}" rule = f'SUBSYSTEM=="tty", ENV{{ID_PATH}}=="{dev["id_path"]}", SYMLINK+="{symlink}"' rules.append(rule) port_num += 1 return rules def main(): import argparse parser = argparse.ArgumentParser( description='Generate or update udev rules for ESP32 devices', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Generate rules from scratch (overwrites existing) %(prog)s --full # Append only new devices to existing rules %(prog)s --append # Dry run (show what would be generated) %(prog)s --append --dry-run """ ) parser.add_argument('--full', action='store_true', help='Generate complete rules file from scratch') parser.add_argument('--append', action='store_true', help='Append only new devices to existing rules') parser.add_argument('--rules-file', default=DEFAULT_RULES_FILE, help=f'Path to udev rules file (default: {DEFAULT_RULES_FILE})') parser.add_argument('--dry-run', action='store_true', help='Show what would be done without writing files') parser.add_argument('--output', help='Write to custom file instead of rules file') args = parser.parse_args() # Default to append mode if neither specified if not args.full and not args.append: args.append = True print(f"{Colors.BLUE}{'='*60}{Colors.RESET}") print(f" ESP32 Udev Rules Generator") print(f"{Colors.BLUE}{'='*60}{Colors.RESET}\n") # Scan connected devices print(f"{Colors.CYAN}Scanning USB topology...{Colors.RESET}") all_devices = scan_usb_devices() if not all_devices: print(f"{Colors.RED}No USB serial devices found.{Colors.RESET}") return 1 print(f"Found {len(all_devices)} USB serial devices\n") if args.full: # Generate complete rules file print(f"{Colors.CYAN}Generating complete rules file...{Colors.RESET}") rules = generate_rules(all_devices, start_port=1) output_file = args.output or args.rules_file print(f"\n{'Physical Path':<20} | {'Current Dev':<15} | {'Assigned Symlink'}") print("-" * 65) for i, dev in enumerate(all_devices): port_num = i + 1 symlink = f"esp_port_{port_num}" print(f"{dev['location']:<20} | {dev['device']:<15} | {symlink}") if not args.dry_run: rules_content = "# Auto-generated by gen_udev_rules.py\n" rules_content += "# Stable port mapping based on USB physical topology (ID_PATH)\n\n" rules_content += "\n".join(rules) + "\n" if output_file == args.rules_file and os.geteuid() != 0: # Write to temp file for user to copy temp_file = "99-esp32-stable.rules" with open(temp_file, 'w') as f: f.write(rules_content) print(f"\n{Colors.YELLOW}Rules written to: {temp_file}{Colors.RESET}") print(f"To install, run:") print(f" {Colors.GREEN}sudo cp {temp_file} {args.rules_file}{Colors.RESET}") else: with open(output_file, 'w') as f: f.write(rules_content) print(f"\n{Colors.GREEN}Rules written to: {output_file}{Colors.RESET}") else: print(f"\n{Colors.YELLOW}DRY RUN: Would generate {len(rules)} rules{Colors.RESET}") else: # append mode # Read existing rules known_paths, max_port = get_existing_rules(args.rules_file) print(f"Existing rules: {len(known_paths)} devices mapped (Max Port: {max_port})") # Find new devices new_devices = [d for d in all_devices if d['id_path'] not in known_paths] if not new_devices: print(f"\n{Colors.GREEN}All connected devices are already mapped. No changes needed.{Colors.RESET}") return 0 print(f"\n{Colors.CYAN}Found {len(new_devices)} UNMAPPED devices:{Colors.RESET}") print(f"{'Physical Path':<20} | {'Current Dev':<15} | {'Assigned Symlink'}") print("-" * 65) new_rules = generate_rules(new_devices, start_port=max_port + 1) for i, dev in enumerate(new_devices): port_num = max_port + 1 + i symlink = f"esp_port_{port_num:02d}" print(f"{dev['location']:<20} | {dev['device']:<15} | {symlink}") if not args.dry_run: # Write new rules to temp file with open(TEMP_RULES_FILE, 'w') as f: f.write("\n# --- Added by gen_udev_rules.py ---\n") f.write("\n".join(new_rules) + "\n") print(f"\n{Colors.CYAN}New rules saved to: {TEMP_RULES_FILE}{Colors.RESET}") print(f"To apply, run:") print(f" {Colors.GREEN}cat {TEMP_RULES_FILE} | sudo tee -a {args.rules_file}{Colors.RESET}") print(f" {Colors.GREEN}sudo udevadm control --reload-rules && sudo udevadm trigger{Colors.RESET}") else: print(f"\n{Colors.YELLOW}DRY RUN: Would append {len(new_rules)} rules{Colors.RESET}") print(f"\n{Colors.BLUE}{'='*60}{Colors.RESET}") return 0 if __name__ == '__main__': sys.exit(main())