From 1288eabd106a50273b0da31cb13bf4e70866b015 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 8 Dec 2025 17:49:54 -0800 Subject: [PATCH] more on mass_deploy --- async_mass_deploy.py | 57 ++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/async_mass_deploy.py b/async_mass_deploy.py index fd6cdc5..bdd7146 100755 --- a/async_mass_deploy.py +++ b/async_mass_deploy.py @@ -23,12 +23,6 @@ except ImportError: sys.exit(1) # --- Configuration --- -# MAX_CONCURRENT_FLASH is limited to 8 to prevent USB bus brownouts. -# Flashing an ESP32 causes high current draw (spikes >300mA). -# Triggering 30+ flashes simultaneously would draw >10A, overwhelming -# standard powered USB hubs and causing devices to reset or disconnect. -# This semaphore queues the flashing step while allowing other steps (boot/config) -# to run fully parallel. MAX_CONCURRENT_FLASH = 8 class Colors: @@ -53,20 +47,22 @@ class DeployWorker: self.build_dir = build_dir self.flash_sem = flash_sem self.log = DeviceLoggerAdapter(logger, {'connid': port}) + + # Regex Patterns self.regex_ready = re.compile(r'Initialization complete|GPS synced|No WiFi config found', re.IGNORECASE) self.regex_got_ip = re.compile(r'got ip:(\d+\.\d+\.\d+\.\d+)', re.IGNORECASE) + self.regex_error = re.compile(r'Error:|Failed|Disconnect|Auth Expire', re.IGNORECASE) async def run(self): try: - # 1. Flash Phase (Semaphore Limited) + # 1. Flash Phase async with self.flash_sem: if self.args.erase: if not await self._erase_flash(): return False if not await self._flash_firmware(): return False - # 2. Config Phase (Fully Parallel) - # Short sleep to let the port release after esptool closes - await asyncio.sleep(0.5) + # 2. Config Phase + await asyncio.sleep(1.0) # Wait for port to stabilize after flash reset if self.args.ssid and self.args.password: if not await self._configure_device(): return False @@ -90,7 +86,6 @@ class DeployWorker: async def _flash_firmware(self): self.log.info("Flashing firmware...") - # Note: flash_args contains relative paths, so we must run from build_dir cmd = ['esptool.py', '-p', self.port, '-b', str(self.args.baud), '--before', 'default_reset', '--after', 'hard_reset', 'write_flash', '@flash_args'] @@ -125,7 +120,8 @@ class DeployWorker: try: self.log.info("Waiting for boot...") booted = False - end_time = time.time() + 8 + # Increased boot timeout slightly + end_time = time.time() + 10 while time.time() < end_time: try: line_b = await asyncio.wait_for(reader.readline(), timeout=0.5) @@ -136,6 +132,9 @@ class DeployWorker: except asyncio.TimeoutError: continue + if not booted: + self.log.warning("Boot prompt missed, sending config blind...") + self.log.info(f"Sending config for {self.target_ip}...") config_str = (f"CFG\nSSID:{self.args.ssid}\nPASS:{self.args.password}\n" f"IP:{self.target_ip}\nMASK:{self.args.netmask}\nGW:{self.args.gateway}\n" @@ -143,21 +142,34 @@ class DeployWorker: writer.write(config_str.encode('utf-8')) await writer.drain() - self.log.info("Verifying IP...") + self.log.info("Verifying IP (Timeout: 30s)...") start_verify = time.time() - while time.time() < start_verify + 10: + + # INCREASED TIMEOUT to 30s + while time.time() < start_verify + 30: try: line_b = await asyncio.wait_for(reader.readline(), timeout=1.0) - line = line_b.decode('utf-8', errors='ignore') + line = line_b.decode('utf-8', errors='ignore').strip() + + # Success Check m = self.regex_got_ip.search(line) if m: if m.group(1) == self.target_ip: self.log.info(f"{Colors.GREEN}SUCCESS: Configured & Connected{Colors.RESET}") return True + else: + self.log.warning(f"IP Mismatch: Got {m.group(1)}, Wanted {self.target_ip}") + + # Failure Check + if self.regex_error.search(line): + self.log.warning(f"Device Reported Error: {line}") + except asyncio.TimeoutError: continue - self.log.error("Config sent, but no IP confirmation received.") + + self.log.error("Timeout: Config sent, but device did not connect.") return False + except Exception as e: self.log.error(f"Config error: {e}") return False @@ -169,7 +181,7 @@ def parse_args(): parser = argparse.ArgumentParser(description='Async ESP32 Mass Deployment') parser.add_argument('-d', '--dir', default=os.getcwd(), help='Project dir') parser.add_argument('-s', '--ssid', help='WiFi SSID') - parser.add_argument('-P', '--password', help='WiFi Password') + parser.add_argument('-P', '--password', help='WiFi Password') # Standardized -P parser.add_argument('--start-ip', default='192.168.1.51', help='Start IP') parser.add_argument('-b', '--baud', type=int, default=460800, help='Flash baud') parser.add_argument('--erase', action='store_true', help='Full erase first') @@ -181,7 +193,7 @@ async def run_deployment(args): project_dir = Path(args.dir).resolve() build_dir = project_dir / 'build' - # 1. Build Firmware (Sync/Blocking is fine here as it's a single pre-step) + # 1. Build Firmware print(f"{Colors.YELLOW}[1/3] Building Firmware...{Colors.RESET}") proc = await asyncio.create_subprocess_exec( 'idf.py', 'build', @@ -200,7 +212,7 @@ async def run_deployment(args): return print(f"{Colors.GREEN}Build Complete.{Colors.RESET}") - # 2. Detect Devices (Sync call to helper) + # 2. Detect Devices print(f"{Colors.YELLOW}[2/3] Scanning Devices...{Colors.RESET}") devices = detect_esp32.detect_esp32_devices() if not devices: @@ -220,7 +232,6 @@ async def run_deployment(args): print("Invalid Start IP") return - # Initialize shared semaphore flash_sem = asyncio.Semaphore(MAX_CONCURRENT_FLASH) tasks = [] @@ -229,7 +240,6 @@ async def run_deployment(args): worker = DeployWorker(dev.device, target_ip, args, build_dir, flash_sem) tasks.append(worker.run()) - # Wait for all workers results = await asyncio.gather(*tasks) # 4. Summary @@ -241,14 +251,9 @@ async def run_deployment(args): print(f"{Colors.BLUE}{'='*40}{Colors.RESET}") def main(): - # 1. Parse Arguments (Sync) args = parse_args() - - # 2. Configure Event Loop (Platform specific) if os.name == 'nt': asyncio.set_event_loop(asyncio.ProactorEventLoop()) - - # 3. Run Async Deployment try: asyncio.run(run_deployment(args)) except KeyboardInterrupt: