From 05fbff009281c91290e4e06606fb832d055cf4af Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 8 Dec 2025 09:19:59 -0800 Subject: [PATCH] add async manual --- async_mass_config.py => async_batch_config.py | 46 ++-- async_batch_config_pro.py | 229 ------------------ doc/ASYNC_MANUAL.md | 48 ++++ doc/async_manual.html | 228 +++++++++++++++++ 4 files changed, 305 insertions(+), 246 deletions(-) rename async_mass_config.py => async_batch_config.py (84%) delete mode 100755 async_batch_config_pro.py create mode 100644 doc/ASYNC_MANUAL.md create mode 100644 doc/async_manual.html 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 @@ + + + + + + ESP32 Fleet Management Manual + + + + +
+ +

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.

+ +
+

🛠️ Critical Step: Set Build Target

+

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.

+
+ +
+ ⚠️ NVS Behavior Summary (Read First)
+ Understanding how Non-Volatile Storage (NVS) is handled is critical to preventing data loss or configuration mismatch. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScriptOperation ModeNVS BehaviorUse Case
async_mass_deploy.pyDefaultPreservedUpdating firmware code without changing IP/WiFi settings.
async_mass_deploy.pyWith SSID/PassPartially RewrittenUpdating firmware AND forcing new WiFi credentials/IPs.
async_mass_deploy.pyWith --eraseWIPED CompletelyFactory reset. Deletes all settings, calibration, and code.
async_batch_config.pyNormal RunPartially RewrittenChanging WiFi/IP settings without touching firmware.
async_find_failed.pyAudit / DiagnosticsRead-OnlyChecking 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:

+ + +

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.

+
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
+ +
+ +

2. async_batch_config.py

+

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.

+ +

NVS Behavior:

+ + +

Usage Examples:

+ +

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
+ +
+ +

3. async_find_failed.py

+

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.

+ +

NVS Behavior:

+ + +

Usage:

+
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.

+ +
+ + +