diff --git a/async_mass_config.py b/async_batch_config.py similarity index 84% rename from async_mass_config.py rename to async_batch_config.py index 30ebf41..15e1187 100755 --- a/async_mass_config.py +++ b/async_batch_config.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """ ESP32 Async Batch Configuration Tool +The definitive parallel configuration tool. Configures 30+ ESP32 devices concurrently using non-blocking I/O. + Features: - Concurrent execution (configure 30 devices in <20 seconds) -- Regex-based state detection (Robust against noise) +- Robust Regex-based state detection +- Supports verifying both Station Mode (IP check) and Monitor Mode - Context-aware logging """ @@ -32,11 +35,10 @@ class DeviceLoggerAdapter(logging.LoggerAdapter): def process(self, msg, kwargs): return '[%s] %s' % (self.extra['connid'], msg), kwargs -# Configure clean logging format logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S') logger = logging.getLogger("BatchConfig") -class AsyncConfigurator: +class Esp32Configurator: """ Manages the lifecycle of configuring a single ESP32 device via Async Serial. """ @@ -46,11 +48,15 @@ class AsyncConfigurator: self.args = args self.log = DeviceLoggerAdapter(logger, {'connid': port}) - # Pre-compile Regex patterns for efficiency (Parsing logic) + # --- Regex Patterns --- + # Success indicators self.regex_got_ip = re.compile(r'got ip:(\d+\.\d+\.\d+\.\d+)', re.IGNORECASE) - self.regex_config_saved = re.compile(r'Config saved|saved to NVS', re.IGNORECASE) - # Prompts that indicate the device is alive and listening + self.regex_monitor_success = re.compile(r'Monitor mode active', re.IGNORECASE) + + # Prompts indicating device is booting/ready self.regex_ready = re.compile(r'Initialization complete|GPS synced|No WiFi config found', re.IGNORECASE) + + # Error indicators self.regex_error = re.compile(r'Error:|Failed|Disconnect', re.IGNORECASE) async def run(self): @@ -62,7 +68,7 @@ class AsyncConfigurator: return False try: - # 1. Hardware Reset + # 1. Hardware Reset (DTR/RTS) self.log.info("Resetting...") writer.transport.serial.dtr = False writer.transport.serial.rts = True @@ -71,9 +77,10 @@ class AsyncConfigurator: await asyncio.sleep(0.1) writer.transport.serial.dtr = True - # 2. Wait for Boot (Regex detection) + # 2. Wait for Boot + # We assume the device is ready when we see logs or a prompt if not await self._wait_for_boot(reader): - self.log.warning("Boot prompt missed, sending config anyway...") + self.log.warning("Boot prompt missed, attempting config anyway...") # 3. Send Configuration await self._send_config(writer) @@ -95,7 +102,6 @@ class AsyncConfigurator: while time.time() < timeout: try: - # Read line with a short timeout to keep checking total time line_bytes = await asyncio.wait_for(reader.readline(), timeout=0.5) line = line_bytes.decode('utf-8', errors='ignore').strip() if not line: continue @@ -110,6 +116,7 @@ class AsyncConfigurator: """Builds and transmits the configuration command""" self.log.info(f"Sending config for IP {self.target_ip}...") + # Construct command block config_str = ( f"CFG\n" f"SSID:{self.args.ssid}\n" @@ -130,9 +137,9 @@ class AsyncConfigurator: await writer.drain() async def _verify_configuration(self, reader): - """Monitors output for confirmation of IP assignment""" + """Monitors output for confirmation of Success""" self.log.info("Verifying configuration...") - timeout = time.time() + 15 # 15s connection timeout + timeout = time.time() + 15 # 15s verification timeout while time.time() < timeout: try: @@ -140,7 +147,7 @@ class AsyncConfigurator: line = line_bytes.decode('utf-8', errors='ignore').strip() if not line: continue - # Check for IP assignment + # Check for Station Mode Success (IP Address) m_ip = self.regex_got_ip.search(line) if m_ip: got_ip = m_ip.group(1) @@ -150,20 +157,25 @@ class AsyncConfigurator: else: self.log.warning(f"MISMATCH: Wanted {self.target_ip}, got {got_ip}") + # Check for Monitor Mode Success + if self.regex_monitor_success.search(line): + self.log.info("SUCCESS: Monitor Mode Active") + return True + # Check for errors if self.regex_error.search(line): - self.log.warning(f"Device Error: {line}") + self.log.warning(f"Device Reported Error: {line}") except asyncio.TimeoutError: continue - self.log.error("Timeout: Device did not report IP address.") + self.log.error("Timeout: Device did not confirm configuration.") return False async def main_async(): parser = argparse.ArgumentParser(description='Async ESP32 Batch Config') - # Arguments matching your existing tools + # Arguments parser.add_argument('--start-ip', required=True, help='Starting Static IP') parser.add_argument('-s', '--ssid', default='ClubHouse2G', help='WiFi SSID') parser.add_argument('-P', '--password', default='ez2remember', help='WiFi password') @@ -201,7 +213,7 @@ async def main_async(): for i, dev in enumerate(devices): current_ip = str(start_ip_obj + i) - configurator = AsyncConfigurator(dev.device, current_ip, args) + configurator = Esp32Configurator(dev.device, current_ip, args) tasks.append(configurator.run()) # Run everything at once diff --git a/async_batch_config_pro.py b/async_batch_config_pro.py deleted file mode 100755 index 14027c1..0000000 --- a/async_batch_config_pro.py +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env python3 -""" -ESP32 Async Batch Configuration Tool (Pro Version) -Incorporates architectural patterns from flows.py: -- Regex-based state detection -- Event-driven synchronization -- Context-aware logging -""" - -import asyncio -import serial_asyncio -import sys -import os -import argparse -import ipaddress -import re -import time -import logging - -# Ensure detection script is available -sys.path.append(os.path.dirname(os.path.abspath(__file__))) -try: - import detect_esp32 -except ImportError: - print("Error: 'detect_esp32.py' not found.") - sys.exit(1) - -# --- Logging Setup (Borrowed concept from flows.py) --- -class DeviceLoggerAdapter(logging.LoggerAdapter): - def process(self, msg, kwargs): - return '[%s] %s' % (self.extra['connid'], msg), kwargs - -logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S') -logger = logging.getLogger("BatchConfig") - -class Esp32Configurator: - """ - Manages the lifecycle of configuring a single ESP32 device. - Uses regex patterns similar to iperf_client in flows.py. - """ - def __init__(self, port, target_ip, config_args): - self.port = port - self.target_ip = target_ip - self.args = config_args - self.log = DeviceLoggerAdapter(logger, {'connid': port}) - - # Regex Patterns (Inspired by flows.py lines 350+) - # We pre-compile these for efficiency - self.regex_got_ip = re.compile(r'got ip:(\d+\.\d+\.\d+\.\d+)', re.IGNORECASE) - self.regex_wifi_connected = re.compile(r'WiFi connected: Yes', re.IGNORECASE) - self.regex_config_saved = re.compile(r'Config saved', re.IGNORECASE) - self.regex_ready_prompt = re.compile(r'Initialization complete|GPS synced|No WiFi config found', re.IGNORECASE) - self.regex_error = re.compile(r'Error:|Failed|Disconnect', re.IGNORECASE) - - async def run(self): - """Main coroutine for this device""" - try: - reader, writer = await serial_asyncio.open_serial_connection(url=self.port, baudrate=115200) - except Exception as e: - self.log.error(f"Failed to open port: {e}") - return False - - try: - # 1. Hardware Reset via DTR/RTS - self.log.info("Resetting...") - writer.transport.serial.dtr = False - writer.transport.serial.rts = True - await asyncio.sleep(0.1) - writer.transport.serial.rts = False - await asyncio.sleep(0.1) - writer.transport.serial.dtr = True - - # 2. Monitor Boot Stream - # We wait until the device settles or we see a prompt - await self._wait_for_boot(reader) - - # 3. Send Configuration - await self._send_config(writer) - - # 4. Verification Loop - success = await self._verify_connection(reader) - return success - - except Exception as e: - self.log.error(f"Process Exception: {e}") - return False - finally: - writer.close() - await writer.wait_closed() - - async def _wait_for_boot(self, reader): - """Consumes boot logs until device looks ready""" - self.log.info("Waiting for boot...") - # Give it a max of 5 seconds to settle or show a prompt - end_time = time.time() + 5 - - while time.time() < end_time: - try: - line_bytes = await asyncio.wait_for(reader.readline(), timeout=0.5) - line = line_bytes.decode('utf-8', errors='ignore').strip() - if not line: continue - - # Check if device is ready to accept commands - if self.regex_ready_prompt.search(line): - self.log.info("Device ready detected.") - return - except asyncio.TimeoutError: - # If silence for 0.5s, it's probably waiting - continue - - async def _send_config(self, writer): - """Constructs and writes the config block""" - self.log.info(f"Sending config for IP {self.target_ip}...") - - config_str = ( - f"CFG\n" - f"SSID:{self.args.ssid}\n" - f"PASS:{self.args.password}\n" - f"IP:{self.target_ip}\n" - f"MASK:{self.args.netmask}\n" - f"GW:{self.args.gateway}\n" - f"DHCP:0\n" - f"BAND:{self.args.band}\n" - f"BW:{self.args.bandwidth}\n" - f"POWERSAVE:{self.args.powersave}\n" - f"MODE:{self.args.mode}\n" - f"MON_CH:{self.args.monitor_channel}\n" - f"END\n" - ) - - # Flush input buffer before writing to ensure clean state - # (Note: asyncio streams don't have a direct flush_input, relies on OS) - writer.write(config_str.encode('utf-8')) - await writer.drain() - - async def _verify_connection(self, reader): - """Reads stream verifying IP assignment""" - self.log.info("Verifying configuration...") - end_time = time.time() + 15 # 15s Timeout - - while time.time() < end_time: - try: - line_bytes = await asyncio.wait_for(reader.readline(), timeout=1.0) - line = line_bytes.decode('utf-8', errors='ignore').strip() - if not line: continue - - # Regex Checks - m_ip = self.regex_got_ip.search(line) - if m_ip: - got_ip = m_ip.group(1) - if got_ip == self.target_ip: - self.log.info(f"SUCCESS: Assigned {got_ip}") - return True - else: - self.log.warning(f"MISMATCH: Wanted {self.target_ip}, got {got_ip}") - - if self.regex_error.search(line): - self.log.warning(f"Device reported error: {line}") - - except asyncio.TimeoutError: - continue - - self.log.error("Timeout waiting for IP confirmation.") - return False - -async def main_async(): - parser = argparse.ArgumentParser(description='Async ESP32 Batch Config (Pro)') - parser.add_argument('--start-ip', required=True, help='Start IP') - parser.add_argument('-s', '--ssid', default='ClubHouse2G') - parser.add_argument('-P', '--password', default='ez2remember') - parser.add_argument('-g', '--gateway', default='192.168.1.1') - parser.add_argument('-m', '--netmask', default='255.255.255.0') - parser.add_argument('-b', '--band', default='2.4G') - parser.add_argument('-B', '--bandwidth', default='HT20') - parser.add_argument('-ps', '--powersave', default='NONE') - parser.add_argument('-M', '--mode', default='STA') - parser.add_argument('-mc', '--monitor-channel', type=int, default=36) - - args = parser.parse_args() - - # 1. Detect - print("Scanning devices...") - devices = detect_esp32.detect_esp32_devices() - if not devices: - print("No devices found.") - return - - # Sort naturally - def natural_keys(d): - return [int(c) if c.isdigit() else c for c in re.split(r'(\d+)', d.device)] - devices.sort(key=natural_keys) - - # 2. Parse IP - try: - start_ip_obj = ipaddress.IPv4Address(args.start_ip) - except: - print("Invalid IP") - return - - # 3. Create Tasks - tasks = [] - print(f"Configuring {len(devices)} devices concurrently...") - - for i, dev in enumerate(devices): - current_ip = str(start_ip_obj + i) - configurator = Esp32Configurator(dev.device, current_ip, args) - tasks.append(configurator.run()) - - # 4. Run All - results = await asyncio.gather(*tasks) - - # 5. Summary - success_count = results.count(True) - print("\n" + "="*40) - print(f"Total: {len(devices)}") - print(f"Success: {success_count}") - print(f"Failed: {len(devices) - success_count}") - print("="*40) - -if __name__ == '__main__': - try: - # Windows/Linux loop compatibility handling (borrowed from ssh_node.py lines 60-65) - if os.name == 'nt': - loop = asyncio.ProactorEventLoop() - asyncio.set_event_loop(loop) - - asyncio.run(main_async()) - except KeyboardInterrupt: - print("\nCancelled.") diff --git a/doc/ASYNC_MANUAL.md b/doc/ASYNC_MANUAL.md new file mode 100644 index 0000000..7e83def --- /dev/null +++ b/doc/ASYNC_MANUAL.md @@ -0,0 +1,48 @@ +# ESP32 Fleet Management Tools: User Manual + +This document provides instructions for using the asynchronous Python tools designed to manage, flash, and configure large fleets (30+) of ESP32 devices simultaneously. + +## ⚠️ Build Target Selection (Carve Out) +**Critical Step:** Before using the mass deployment tools, you must define which chip architecture you are building for. The `async_mass_deploy.py` script relies on the project's current configuration. + +Run **one** of the following commands in the project root to set the target: + +| Target Hardware | Command | +| :--- | :--- | +| **Original ESP32** | `idf.py set-target esp32` | +| **ESP32-S3** | `idf.py set-target esp32s3` | +| **ESP32-C5** | `idf.py set-target esp32c5` | + +*Note: Changing the target forces a full clean rebuild. Ensure you see a successful configuration message before proceeding to deployment.* + +--- + +## NVS Behavior Summary +Understanding how Non-Volatile Storage (NVS) is handled is critical to preventing data loss or configuration mismatch. + +| Script | Operation Mode | NVS Behavior | Use Case | +| :--- | :--- | :--- | :--- | +| **async_mass_deploy.py** | Default (No credentials) | **Preserved** | Updating firmware code without changing IP/WiFi settings. | +| **async_mass_deploy.py** | With SSID/Pass | **Partially Rewritten** | Updating firmware AND forcing new WiFi credentials/IPs. | +| **async_mass_deploy.py** | With `--erase` | **WIPED Completely** | Factory reset. Deletes all settings, calibration, and code. | +| **async_batch_config.py** | Normal Run | **Partially Rewritten** | Changing WiFi/IP settings *without* touching firmware. | +| **async_find_failed.py** | Audit / Diagnostics | **Read-Only** | Checking status. No changes to NVS. | + +--- + +## 1. async_mass_deploy.py +**The "Factory Floor" Tool.** Use this to flash the compiled binary (`.bin`) to the chips. It combines building, flashing (via `esptool`), and optional initial configuration. + +### When to use: +* You have new, blank ESP32 chips. +* You have modified the C code (`main.c`) and need to update the firmware. +* Devices are stuck in a boot loop and need a full erase. + + + +### Usage Examples: + +**A. Firmware Update Only (Preserve Settings)** +Updates the code but keeps the existing WiFi SSID, Password, and Static IP stored on the device. +```bash +python3 async_mass_deploy.py \ No newline at end of file diff --git a/doc/async_manual.html b/doc/async_manual.html new file mode 100644 index 0000000..34ce51c --- /dev/null +++ b/doc/async_manual.html @@ -0,0 +1,228 @@ + + +
+ + +This document provides instructions for using the asynchronous Python tools designed to manage, flash, and configure large fleets (30+) of ESP32 devices simultaneously.
+ +Before running async_mass_deploy.py, you must configure the project for your specific chip architecture. The deployment script uses the currently configured target.
Run one of the following commands in your project root before deployment:
+ +For Original ESP32:
+idf.py set-target esp32
+
+ For ESP32-S3:
+idf.py set-target esp32s3
+
+ For ESP32-C5:
+idf.py set-target esp32c5
+
+ Note: Changing the target triggers a full re-build. Ensure the build completes successfully before attempting mass deployment.
+| Script | +Operation Mode | +NVS Behavior | +Use Case | +
|---|---|---|---|
async_mass_deploy.py |
+ Default | +Preserved | +Updating firmware code without changing IP/WiFi settings. | +
async_mass_deploy.py |
+ With SSID/Pass | +Partially Rewritten | +Updating firmware AND forcing new WiFi credentials/IPs. | +
async_mass_deploy.py |
+ With --erase |
+ WIPED Completely | +Factory reset. Deletes all settings, calibration, and code. | +
async_batch_config.py |
+ Normal Run | +Partially Rewritten | +Changing WiFi/IP settings without touching firmware. | +
async_find_failed.py |
+ Audit / Diagnostics | +Read-Only | +Checking status. No changes to NVS. | +
The "Factory Floor" Tool. Use this to flash the compiled binary (.bin) to the chips. It combines building, flashing (via esptool), and optional initial configuration.
main.c) and need to update the firmware.A. Firmware Update Only (Preserve Settings)
+ Updates the code but keeps the existing WiFi SSID, Password, and Static IP stored on the device.
python3 async_mass_deploy.py
+
+ B. Full Factory Deployment (Erase & Provision)
+ Wipes the chip clean, flashes new code, and sets up WiFi/IPs from scratch.
python3 async_mass_deploy.py \
+ --erase \
+ --start-ip 192.168.1.101 \
+ -s ClubHouse2G \
+ -p ez2remember
+
+ The "Field Update" Tool. Use this to change settings (IP, WiFi, Mode) on devices that are already running valid firmware. It is much faster than mass_deploy because it does not flash binaries.
A. Configure for Station Mode (Standard)
+ Sets sequential IPs starting at .101.
python3 async_batch_config.py \
+ --start-ip 192.168.1.101 \
+ -s ClubHouse2G \
+ -P ez2remember
+
+ B. Configure for Monitor Mode (Sniffer)
+ Sets devices to listen promiscuously on Channel 36.
python3 async_batch_config.py \
+ --start-ip 192.168.1.101 \
+ -s ClubHouse2G \
+ -P ez2remember \
+ -M MONITOR \
+ -mc 36 \
+ -ps NONE
+
+ The "Doctor" Tool. Use this to audit the status of the fleet. It does not flash or configure; it only asks the devices "Are you okay?" and displays the result.
+ +python3 async_find_failed.py
+ Output: Displays a table of Port, IP, Mode, and LED status.
+ Interactive Menu: Allows you to reboot or send reconnect commands to failed devices found during the scan.