add async manual

This commit is contained in:
Bob 2025-12-08 09:19:59 -08:00
parent 0a4bce5bf6
commit 05fbff0092
4 changed files with 305 additions and 246 deletions

View File

@ -1,10 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ESP32 Async Batch Configuration Tool ESP32 Async Batch Configuration Tool
The definitive parallel configuration tool.
Configures 30+ ESP32 devices concurrently using non-blocking I/O. Configures 30+ ESP32 devices concurrently using non-blocking I/O.
Features: Features:
- Concurrent execution (configure 30 devices in <20 seconds) - 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 - Context-aware logging
""" """
@ -32,11 +35,10 @@ class DeviceLoggerAdapter(logging.LoggerAdapter):
def process(self, msg, kwargs): def process(self, msg, kwargs):
return '[%s] %s' % (self.extra['connid'], 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') logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S')
logger = logging.getLogger("BatchConfig") logger = logging.getLogger("BatchConfig")
class AsyncConfigurator: class Esp32Configurator:
""" """
Manages the lifecycle of configuring a single ESP32 device via Async Serial. Manages the lifecycle of configuring a single ESP32 device via Async Serial.
""" """
@ -46,11 +48,15 @@ class AsyncConfigurator:
self.args = args self.args = args
self.log = DeviceLoggerAdapter(logger, {'connid': port}) 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_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) self.regex_monitor_success = re.compile(r'Monitor mode active', re.IGNORECASE)
# Prompts that indicate the device is alive and listening
# Prompts indicating device is booting/ready
self.regex_ready = re.compile(r'Initialization complete|GPS synced|No WiFi config found', re.IGNORECASE) 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) self.regex_error = re.compile(r'Error:|Failed|Disconnect', re.IGNORECASE)
async def run(self): async def run(self):
@ -62,7 +68,7 @@ class AsyncConfigurator:
return False return False
try: try:
# 1. Hardware Reset # 1. Hardware Reset (DTR/RTS)
self.log.info("Resetting...") self.log.info("Resetting...")
writer.transport.serial.dtr = False writer.transport.serial.dtr = False
writer.transport.serial.rts = True writer.transport.serial.rts = True
@ -71,9 +77,10 @@ class AsyncConfigurator:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
writer.transport.serial.dtr = True 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): 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 # 3. Send Configuration
await self._send_config(writer) await self._send_config(writer)
@ -95,7 +102,6 @@ class AsyncConfigurator:
while time.time() < timeout: while time.time() < timeout:
try: try:
# Read line with a short timeout to keep checking total time
line_bytes = await asyncio.wait_for(reader.readline(), timeout=0.5) line_bytes = await asyncio.wait_for(reader.readline(), timeout=0.5)
line = line_bytes.decode('utf-8', errors='ignore').strip() line = line_bytes.decode('utf-8', errors='ignore').strip()
if not line: continue if not line: continue
@ -110,6 +116,7 @@ class AsyncConfigurator:
"""Builds and transmits the configuration command""" """Builds and transmits the configuration command"""
self.log.info(f"Sending config for IP {self.target_ip}...") self.log.info(f"Sending config for IP {self.target_ip}...")
# Construct command block
config_str = ( config_str = (
f"CFG\n" f"CFG\n"
f"SSID:{self.args.ssid}\n" f"SSID:{self.args.ssid}\n"
@ -130,9 +137,9 @@ class AsyncConfigurator:
await writer.drain() await writer.drain()
async def _verify_configuration(self, reader): async def _verify_configuration(self, reader):
"""Monitors output for confirmation of IP assignment""" """Monitors output for confirmation of Success"""
self.log.info("Verifying configuration...") self.log.info("Verifying configuration...")
timeout = time.time() + 15 # 15s connection timeout timeout = time.time() + 15 # 15s verification timeout
while time.time() < timeout: while time.time() < timeout:
try: try:
@ -140,7 +147,7 @@ class AsyncConfigurator:
line = line_bytes.decode('utf-8', errors='ignore').strip() line = line_bytes.decode('utf-8', errors='ignore').strip()
if not line: continue if not line: continue
# Check for IP assignment # Check for Station Mode Success (IP Address)
m_ip = self.regex_got_ip.search(line) m_ip = self.regex_got_ip.search(line)
if m_ip: if m_ip:
got_ip = m_ip.group(1) got_ip = m_ip.group(1)
@ -150,20 +157,25 @@ class AsyncConfigurator:
else: else:
self.log.warning(f"MISMATCH: Wanted {self.target_ip}, got {got_ip}") 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 # Check for errors
if self.regex_error.search(line): if self.regex_error.search(line):
self.log.warning(f"Device Error: {line}") self.log.warning(f"Device Reported Error: {line}")
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
self.log.error("Timeout: Device did not report IP address.") self.log.error("Timeout: Device did not confirm configuration.")
return False return False
async def main_async(): async def main_async():
parser = argparse.ArgumentParser(description='Async ESP32 Batch Config') 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('--start-ip', required=True, help='Starting Static IP')
parser.add_argument('-s', '--ssid', default='ClubHouse2G', help='WiFi SSID') parser.add_argument('-s', '--ssid', default='ClubHouse2G', help='WiFi SSID')
parser.add_argument('-P', '--password', default='ez2remember', help='WiFi password') parser.add_argument('-P', '--password', default='ez2remember', help='WiFi password')
@ -201,7 +213,7 @@ async def main_async():
for i, dev in enumerate(devices): for i, dev in enumerate(devices):
current_ip = str(start_ip_obj + i) 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()) tasks.append(configurator.run())
# Run everything at once # Run everything at once

View File

@ -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.")

48
doc/ASYNC_MANUAL.md Normal file
View File

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

228
doc/async_manual.html Normal file
View File

@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 Fleet Management Manual</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 900px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.container {
background-color: #fff;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 { border-bottom: 2px solid #eaeaea; padding-bottom: 10px; color: #2c3e50; }
h2 { margin-top: 30px; color: #34495e; border-bottom: 1px solid #eee; padding-bottom: 5px; }
h3 { margin-top: 20px; color: #455a64; }
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th { background-color: #f2f2f2; color: #333; }
tr:nth-child(even) { background-color: #f9f9f9; }
code {
background-color: #f4f4f4;
padding: 2px 5px;
border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
color: #d63384;
}
pre {
background-color: #2d3436;
color: #dfe6e9;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
pre code {
background-color: transparent;
color: inherit;
padding: 0;
}
.alert {
background-color: #fff3cd;
border: 1px solid #ffeeba;
color: #856404;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.info-box {
background-color: #e1f5fe;
border-left: 5px solid #039be5;
padding: 15px;
margin-bottom: 20px;
}
.tag-green { color: #27ae60; font-weight: bold; }
.tag-yellow { color: #f39c12; font-weight: bold; }
.tag-red { color: #c0392b; font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<h1>ESP32 Fleet Management Tools: User Manual</h1>
<p>This document provides instructions for using the asynchronous Python tools designed to manage, flash, and configure large fleets (30+) of ESP32 devices simultaneously.</p>
<div class="info-box">
<h2>🛠️ Critical Step: Set Build Target</h2>
<p>Before running <code>async_mass_deploy.py</code>, you <strong>must</strong> configure the project for your specific chip architecture. The deployment script uses the currently configured target.</p>
<p>Run <strong>one</strong> of the following commands in your project root before deployment:</p>
<p><strong>For Original ESP32:</strong></p>
<pre><code>idf.py set-target esp32</code></pre>
<p><strong>For ESP32-S3:</strong></p>
<pre><code>idf.py set-target esp32s3</code></pre>
<p><strong>For ESP32-C5:</strong></p>
<pre><code>idf.py set-target esp32c5</code></pre>
<p><em>Note: Changing the target triggers a full re-build. Ensure the build completes successfully before attempting mass deployment.</em></p>
</div>
<div class="alert">
<strong>⚠️ NVS Behavior Summary (Read First)</strong><br>
Understanding how Non-Volatile Storage (NVS) is handled is critical to preventing data loss or configuration mismatch.
</div>
<table>
<thead>
<tr>
<th>Script</th>
<th>Operation Mode</th>
<th>NVS Behavior</th>
<th>Use Case</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>async_mass_deploy.py</code></td>
<td>Default</td>
<td><span class="tag-green">Preserved</span></td>
<td>Updating firmware code without changing IP/WiFi settings.</td>
</tr>
<tr>
<td><code>async_mass_deploy.py</code></td>
<td>With SSID/Pass</td>
<td><span class="tag-yellow">Partially Rewritten</span></td>
<td>Updating firmware AND forcing new WiFi credentials/IPs.</td>
</tr>
<tr>
<td><code>async_mass_deploy.py</code></td>
<td>With <code>--erase</code></td>
<td><span class="tag-red">WIPED Completely</span></td>
<td>Factory reset. Deletes all settings, calibration, and code.</td>
</tr>
<tr>
<td><code>async_batch_config.py</code></td>
<td>Normal Run</td>
<td><span class="tag-yellow">Partially Rewritten</span></td>
<td>Changing WiFi/IP settings <em>without</em> touching firmware.</td>
</tr>
<tr>
<td><code>async_find_failed.py</code></td>
<td>Audit / Diagnostics</td>
<td><span class="tag-green">Read-Only</span></td>
<td>Checking status. No changes to NVS.</td>
</tr>
</tbody>
</table>
<hr>
<h2>1. async_mass_deploy.py</h2>
<p><strong>The "Factory Floor" Tool.</strong> Use this to flash the compiled binary (<code>.bin</code>) to the chips. It combines building, flashing (via <code>esptool</code>), and optional initial configuration.</p>
<h3>When to use:</h3>
<ul>
<li>You have new, blank ESP32 chips.</li>
<li>You have modified the C code (<code>main.c</code>) and need to update the firmware.</li>
<li>Devices are stuck in a boot loop and need a full erase.</li>
</ul>
<h3>Usage Examples:</h3>
<p><strong>A. Firmware Update Only (Preserve Settings)</strong><br>
Updates the code but keeps the existing WiFi SSID, Password, and Static IP stored on the device.</p>
<pre><code>python3 async_mass_deploy.py</code></pre>
<p><strong>B. Full Factory Deployment (Erase & Provision)</strong><br>
Wipes the chip clean, flashes new code, and sets up WiFi/IPs from scratch.</p>
<pre><code>python3 async_mass_deploy.py \
--erase \
--start-ip 192.168.1.101 \
-s ClubHouse2G \
-p ez2remember</code></pre>
<hr>
<h2>2. async_batch_config.py</h2>
<p><strong>The "Field Update" Tool.</strong> Use this to change settings (IP, WiFi, Mode) on devices that are <em>already</em> running valid firmware. It is much faster than <code>mass_deploy</code> because it does not flash binaries.</p>
<h3>NVS Behavior:</h3>
<ul>
<li><strong>Rewrites:</strong> SSID, Password, IP, Gateway, Netmask, Mode, Bandwidth.</li>
<li><strong>Preserves:</strong> Any other NVS keys not explicitly touched by the config command.</li>
</ul>
<h3>Usage Examples:</h3>
<p><strong>A. Configure for Station Mode (Standard)</strong><br>
Sets sequential IPs starting at .101.</p>
<pre><code>python3 async_batch_config.py \
--start-ip 192.168.1.101 \
-s ClubHouse2G \
-P ez2remember</code></pre>
<p><strong>B. Configure for Monitor Mode (Sniffer)</strong><br>
Sets devices to listen promiscuously on Channel 36.</p>
<pre><code>python3 async_batch_config.py \
--start-ip 192.168.1.101 \
-s ClubHouse2G \
-P ez2remember \
-M MONITOR \
-mc 36 \
-ps NONE</code></pre>
<hr>
<h2>3. async_find_failed.py</h2>
<p><strong>The "Doctor" Tool.</strong> 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.</p>
<h3>NVS Behavior:</h3>
<ul>
<li><strong>Read-Only:</strong> Does not write to NVS during scanning.</li>
<li><strong>Recovery:</strong> The "Soft Reconnect" option sends a command to retry WiFi, but does not permanently alter the saved config.</li>
</ul>
<h3>Usage:</h3>
<pre><code>python3 async_find_failed.py</code></pre>
<p><em>Output:</em> Displays a table of Port, IP, Mode, and LED status.<br>
<em>Interactive Menu:</em> Allows you to reboot or send reconnect commands to failed devices found during the scan.</p>
</div>
</body>
</html>