fixes to mass_deploy for concurrency

This commit is contained in:
Bob 2025-12-07 20:18:36 -08:00
parent 0e2cea8da7
commit bb0e1814b6
2 changed files with 308 additions and 321 deletions

158
batch_config.py Executable file
View File

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
ESP32 Batch Configuration Tool
Detects all connected ESP32s and configures them with sequential Static IPs.
Requires: detect_esp32.py and config_device.py in the same directory.
"""
import sys
import os
import argparse
import time
import ipaddress
import re
# Ensure we can import the other scripts
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
import detect_esp32
import config_device
except ImportError as e:
print(f"Error: Could not import required modules ({e}).")
print("Make sure 'detect_esp32.py' and 'config_device.py' are in the same folder.")
sys.exit(1)
def natural_sort_key(device_obj):
"""
Sorts ports naturally (ttyUSB2 comes before ttyUSB10)
"""
s = device_obj.device
# Split string into a list of integers and non-integers
return [int(text) if text.isdigit() else text.lower()
for text in re.split('([0-9]+)', s)]
def main():
parser = argparse.ArgumentParser(
description='Batch Config: Detects all ESP32s and configures sequential IPs',
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
# Arguments matching config_device.py options
parser.add_argument('--start-ip', required=True,
help='Starting Static IP (e.g., 192.168.1.101). Will increment for each device.')
parser.add_argument('-s', '--ssid', default='ClubHouse2G',
help='WiFi SSID')
parser.add_argument('-P', '--password', default='ez2remember',
help='WiFi password')
parser.add_argument('-g', '--gateway', default='192.168.1.1',
help='Gateway IP')
parser.add_argument('-m', '--netmask', default='255.255.255.0',
help='Netmask')
parser.add_argument('-b', '--band', default='2.4G', choices=['2.4G', '5G'],
help='WiFi band')
parser.add_argument('-B', '--bandwidth', default='HT20', choices=['HT20', 'HT40', 'VHT80'],
help='Channel bandwidth')
parser.add_argument('-ps', '--powersave', default='NONE',
choices=['NONE', 'MIN', 'MIN_MODEM', 'MAX', 'MAX_MODEM'],
help='Power save mode')
parser.add_argument('-M', '--mode', default='STA', choices=['STA', 'MONITOR'],
help='Operating mode')
parser.add_argument('-mc', '--monitor-channel', type=int, default=36,
help='Monitor mode channel')
parser.add_argument('-r', '--no-reboot', action='store_true',
help='Do NOT reboot devices after configuration')
parser.add_argument('-v', '--verbose', action='store_true',
help='Enable verbose output')
args = parser.parse_args()
# 1. Detect Devices
print(f"{'='*60}")
print("Step 1: Detecting ESP32 Devices...")
print(f"{'='*60}")
devices = detect_esp32.detect_esp32_devices()
if not devices:
print("No ESP32 devices found! Check USB connections.")
sys.exit(1)
# Sort devices naturally so IPs are assigned in order (USB0, USB1, USB2...)
devices.sort(key=natural_sort_key)
print(f"Found {len(devices)} devices:")
for d in devices:
print(f" - {d.device} ({d.description})")
print()
# 2. Parse Starting IP
try:
start_ip_obj = ipaddress.IPv4Address(args.start_ip)
except ipaddress.AddressValueError:
print(f"Error: Invalid IP address format: {args.start_ip}")
sys.exit(1)
# 3. Configure Each Device
print(f"{'='*60}")
print("Step 2: Configuring Devices Sequentially")
print(f"{'='*60}")
success_count = 0
fail_count = 0
failed_devices = []
for index, device in enumerate(devices):
# Calculate current IP
current_ip = str(start_ip_obj + index)
port = device.device
print(f"\n[{index+1}/{len(devices)}] Configuring {port} with IP {current_ip}...")
# Call the config function from your existing script
result = config_device.config_device(
port=port,
ip=current_ip,
ssid=args.ssid,
password=args.password,
gateway=args.gateway,
netmask=args.netmask,
band=args.band,
bandwidth=args.bandwidth,
powersave=args.powersave,
mode=args.mode,
monitor_channel=args.monitor_channel,
reboot=not args.no_reboot,
verbose=args.verbose
)
if result:
print(f"✓ Success: {port} -> {current_ip}")
success_count += 1
else:
print(f"✗ Failed: {port}")
fail_count += 1
failed_devices.append(port)
# Small delay to prevent USB power spikes if multiple devices reboot simultaneously
if not args.no_reboot and index < len(devices) - 1:
time.sleep(1.0)
# 4. Summary
print(f"\n{'='*60}")
print("Batch Configuration Complete")
print(f"{'='*60}")
print(f"Total Devices: {len(devices)}")
print(f"Successful: {success_count}")
print(f"Failed: {fail_count}")
if failed_devices:
print("\nFailed Ports:")
for p in failed_devices:
print(f" - {p}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nBatch process interrupted by user.")

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
ESP32 Mass Deployment Tool
Parallel flashing and WiFi configuration for multiple ESP32 devices
ESP32 Mass Deployment Tool (Fixed for Parallel Flashing)
Uses esptool.py directly to bypass CMake locking issues.
"""
import os
@ -10,6 +10,7 @@ import subprocess
import glob
import time
import argparse
import serial
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
@ -18,7 +19,7 @@ class Colors:
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
NC = '\033[0m' # No Color
NC = '\033[0m'
class DeviceDeployer:
def __init__(self, project_dir, ssid, password, start_ip="192.168.1.51",
@ -39,89 +40,96 @@ class DeviceDeployer:
self.verbose = verbose
self.parallel = parallel
# Parse IP address
ip_parts = start_ip.split('.')
self.ip_base = '.'.join(ip_parts[:3])
self.ip_start = int(ip_parts[3])
# Mode detection
self.config_mode = (self.ssid is not None and self.password is not None)
if self.start_ip:
ip_parts = start_ip.split('.')
self.ip_base = '.'.join(ip_parts[:3])
self.ip_start = int(ip_parts[3])
else:
self.ip_base = "192.168.1"
self.ip_start = 51
self.devices = []
self.results = {}
self.log_dir = Path('/tmp')
def print_banner(self):
"""Print deployment configuration"""
print()
print(f"{Colors.BLUE}{'='*70}")
print("ESP32 Mass Deployment Tool")
print(f"{'='*70}{Colors.NC}")
print(f"Project: {self.project_dir}")
print(f"SSID: {self.ssid}")
print(f"Password: {'*' * len(self.password)}")
print(f"Start IP: {self.start_ip}")
print(f"Gateway: {self.gateway}")
print(f"Netmask: {self.netmask}")
print(f"Baud Rate: {self.baud_rate}")
print(f"Max Retries: {self.max_retries}")
print(f"Verify Ping: {self.verify_ping}")
print(f"Mode: {'Parallel' if self.parallel else 'Sequential'}")
if self.config_mode:
print(f"Mode: {Colors.YELLOW}FLASH + CONFIGURE{Colors.NC}")
print(f"SSID: {self.ssid}")
print(f"Password: {'*' * len(self.password)}")
print(f"Start IP: {self.start_ip}")
else:
print(f"Mode: {Colors.GREEN}FLASH ONLY (Preserve NVS){Colors.NC}")
print(f"Flash Baud: {self.baud_rate}")
print(f"Parallel: {self.parallel}")
if self.num_devices:
print(f"Max Devices: {self.num_devices}")
print(f"{Colors.BLUE}{'='*70}{Colors.NC}")
def build_firmware(self):
"""Build the firmware using idf.py"""
"""Build firmware once to generate flash_args"""
print()
print(f"{Colors.YELLOW}[1/4] Building firmware...{Colors.NC}")
try:
result = subprocess.run(
# We run build to ensure binary and flash_args exist
subprocess.run(
['idf.py', 'build'],
cwd=self.project_dir,
check=True,
capture_output=not self.verbose
)
print(f"{Colors.GREEN}✓ Build complete{Colors.NC}")
# Verify flash_args exists (critical for parallel flashing)
flash_args_path = self.project_dir / 'build' / 'flash_args'
if not flash_args_path.exists():
print(f"{Colors.RED}Error: build/flash_args not found.{Colors.NC}")
return False
print(f"{Colors.GREEN}✓ Build complete (ready for parallel flash){Colors.NC}")
return True
except subprocess.CalledProcessError as e:
print(f"{Colors.RED}✗ Build failed!{Colors.NC}")
if self.verbose:
print(e.stderr.decode() if e.stderr else "")
if self.verbose: print(e.stderr.decode() if e.stderr else "")
return False
def detect_devices(self):
"""Detect connected ESP32 devices"""
print()
print(f"{Colors.YELLOW}[2/4] Detecting ESP32 devices...{Colors.NC}")
# Find all USB serial devices
self.devices = sorted(glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*'))
if not self.devices:
print(f"{Colors.RED}ERROR: No devices found!{Colors.NC}")
print("Connect ESP32 devices via USB and try again.")
return False
# Limit to num_devices if specified
if self.num_devices and len(self.devices) > self.num_devices:
print(f"Limiting to first {self.num_devices} devices")
self.devices = self.devices[:self.num_devices]
print(f"{Colors.GREEN}Found {len(self.devices)} device(s):{Colors.NC}")
for i, device in enumerate(self.devices):
ip = self.get_ip_for_index(i)
print(f" [{i:2d}] {device:14s}{ip}")
if self.config_mode:
print(f" [{i:2d}] {device:14s}{self.get_ip_for_index(i)}")
else:
print(f" [{i:2d}] {device:14s} → (Existing IP)")
return True
def get_ip_for_index(self, index):
"""Calculate IP address for device index"""
return f"{self.ip_base}.{self.ip_start + index}"
def flash_and_configure(self, index, device):
"""Flash and configure a single device"""
ip_addr = self.get_ip_for_index(index)
target_ip = self.get_ip_for_index(index) if self.config_mode else "Existing IP"
log_file = self.log_dir / f"esp32_deploy_{index}.log"
log_lines = []
flash_args_file = self.project_dir / 'build' / 'flash_args'
def log(msg):
log_lines.append(msg)
@ -130,373 +138,194 @@ class DeviceDeployer:
for attempt in range(1, self.max_retries + 1):
log(f"=== Device {index}: {device} (Attempt {attempt}/{self.max_retries}) ===")
log(f"Target IP: {ip_addr}")
# Flash firmware
log("Flashing...")
# --- FLASHING (Using esptool.py directly) ---
log("Flashing via esptool...")
try:
# Construct command: esptool.py -p PORT -b BAUD --before default_reset --after hard_reset write_flash @build/flash_args
cmd = [
'esptool.py',
'-p', device,
'-b', str(self.baud_rate),
'--before', 'default_reset',
'--after', 'hard_reset',
'write_flash',
f"@{flash_args_file}"
]
result = subprocess.run(
['idf.py', '-p', device, '-b', str(self.baud_rate), 'flash'],
cwd=self.project_dir,
cmd,
cwd=self.project_dir, # Run from project dir so relative paths in flash_args work
check=True,
capture_output=True,
timeout=300 # 5 minute timeout
timeout=300
)
log("✓ Flash successful")
except subprocess.CalledProcessError as e:
log(f"✗ Flash failed on attempt {attempt}")
log(f"✗ Flash failed: {e.stderr.decode() if e.stderr else 'Unknown error'}")
if attempt == self.max_retries:
# Write log file
with open(log_file, 'w') as f:
f.write('\n'.join(log_lines))
return {
'index': index,
'device': device,
'ip': ip_addr,
'status': 'FAILED',
'log': log_lines
}
with open(log_file, 'w') as f: f.write('\n'.join(log_lines))
return {'index': index, 'device': device, 'ip': target_ip, 'status': 'FAILED', 'log': log_lines}
time.sleep(2)
continue
except subprocess.TimeoutExpired:
log(f"✗ Flash timeout on attempt {attempt}")
log("✗ Flash timeout")
if attempt == self.max_retries:
with open(log_file, 'w') as f:
f.write('\n'.join(log_lines))
return {
'index': index,
'device': device,
'ip': ip_addr,
'status': 'TIMEOUT',
'log': log_lines
}
with open(log_file, 'w') as f: f.write('\n'.join(log_lines))
return {'index': index, 'device': device, 'ip': target_ip, 'status': 'TIMEOUT', 'log': log_lines}
continue
# Wait for device to boot
log("Waiting for boot...")
# --- CONFIGURATION ---
log("Waiting for boot (3s)...")
time.sleep(3)
# Configure WiFi
log("Configuring WiFi...")
try:
config = (
f"CFG\n"
f"SSID:{self.ssid}\n"
f"PASS:{self.password}\n"
f"IP:{ip_addr}\n"
f"MASK:{self.netmask}\n"
f"GW:{self.gateway}\n"
f"DHCP:0\n"
f"END\n"
)
with open(device, 'w') as f:
f.write(config)
log("✓ Config sent")
except Exception as e:
log(f"✗ Config error: {e}")
# Wait for network to initialize
log("Waiting for network...")
time.sleep(5)
# Verify connectivity
if self.verify_ping:
log("Verifying connectivity...")
if self.config_mode:
log(f"Configuring WiFi ({target_ip})...")
try:
result = subprocess.run(
['ping', '-c', '2', '-W', '3', ip_addr],
capture_output=True,
timeout=10
config = (
f"CFG\n"
f"SSID:{self.ssid}\n"
f"PASS:{self.password}\n"
f"IP:{target_ip}\n"
f"MASK:{self.netmask}\n"
f"GW:{self.gateway}\n"
f"DHCP:0\n"
f"END\n"
)
if result.returncode == 0:
log("✓ Ping successful")
# Write log file
with open(log_file, 'w') as f:
f.write('\n'.join(log_lines))
return {
'index': index,
'device': device,
'ip': ip_addr,
'status': 'SUCCESS',
'log': log_lines
}
with serial.Serial(device, 115200, timeout=2, write_timeout=2) as ser:
ser.reset_input_buffer()
ser.write(config.encode('utf-8'))
ser.flush()
log("✓ Config sent")
if self.verify_ping:
log("Waiting for network (6s)...")
time.sleep(6)
log(f"Pinging {target_ip}...")
try:
res = subprocess.run(['ping', '-c', '2', '-W', '3', target_ip], capture_output=True, timeout=10)
if res.returncode == 0:
log("✓ Ping successful")
with open(log_file, 'w') as f: f.write('\n'.join(log_lines))
return {'index': index, 'device': device, 'ip': target_ip, 'status': 'SUCCESS', 'log': log_lines}
else:
log("✗ Ping failed")
# Fall through to retry loop or fail
except:
log("✗ Ping error")
else:
log(f"✗ Ping failed on attempt {attempt}")
if attempt == self.max_retries:
with open(log_file, 'w') as f:
f.write('\n'.join(log_lines))
return {
'index': index,
'device': device,
'ip': ip_addr,
'status': 'NO_PING',
'log': log_lines
}
except subprocess.TimeoutExpired:
log(f"✗ Ping timeout on attempt {attempt}")
with open(log_file, 'w') as f: f.write('\n'.join(log_lines))
return {'index': index, 'device': device, 'ip': target_ip, 'status': 'SUCCESS', 'log': log_lines}
except Exception as e:
log(f"✗ Config error: {e}")
else:
# Write log file
with open(log_file, 'w') as f:
f.write('\n'.join(log_lines))
return {
'index': index,
'device': device,
'ip': ip_addr,
'status': 'SUCCESS',
'log': log_lines
}
log("Configuration skipped (Preserving NVS)")
with open(log_file, 'w') as f: f.write('\n'.join(log_lines))
return {'index': index, 'device': device, 'ip': target_ip, 'status': 'SUCCESS', 'log': log_lines}
time.sleep(2)
# If we get here, all retries failed
with open(log_file, 'w') as f:
f.write('\n'.join(log_lines))
return {
'index': index,
'device': device,
'ip': ip_addr,
'status': 'FAILED',
'log': log_lines
}
# Final failure return
with open(log_file, 'w') as f: f.write('\n'.join(log_lines))
return {'index': index, 'device': device, 'ip': target_ip, 'status': 'FAILED', 'log': log_lines}
def deploy_all_parallel(self):
"""Deploy to all devices in parallel"""
print()
print(f"{Colors.YELLOW}[3/4] Flashing and configuring (parallel)...{Colors.NC}")
print()
print(f"{Colors.YELLOW}[3/4] Flashing (parallel)...{Colors.NC}")
# Clean old log files
for f in self.log_dir.glob('esp32_deploy_*.log'):
f.unlink()
# Use ThreadPoolExecutor for parallel execution
max_workers = min(32, len(self.devices))
# 10 workers is a safe limit for USB hubs
max_workers = min(10, len(self.devices))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all jobs
futures = {
executor.submit(self.flash_and_configure, i, device): (i, device)
for i, device in enumerate(self.devices)
}
# Collect results as they complete
for future in as_completed(futures):
result = future.result()
self.results[result['index']] = result
# Print immediate status
self.print_device_status(result)
def deploy_all_sequential(self):
"""Deploy to devices one at a time (sequential)"""
print()
print(f"{Colors.YELLOW}[3/4] Flashing and configuring (sequential)...{Colors.NC}")
print()
# Clean old log files
for f in self.log_dir.glob('esp32_deploy_*.log'):
f.unlink()
print(f"{Colors.YELLOW}[3/4] Flashing (sequential)...{Colors.NC}")
for i, device in enumerate(self.devices):
print(f"\n{Colors.BLUE}--- Device {i+1}/{len(self.devices)} ---{Colors.NC}")
result = self.flash_and_configure(i, device)
self.results[result['index']] = result
# Print status after each device
self.print_device_status(result)
print()
def print_device_status(self, result):
"""Print status for a single device"""
status_color = {
'SUCCESS': Colors.GREEN,
'NO_PING': Colors.YELLOW,
'FAILED': Colors.RED,
'TIMEOUT': Colors.RED
'SUCCESS': Colors.GREEN, 'NO_PING': Colors.YELLOW,
'FAILED': Colors.RED, 'TIMEOUT': Colors.RED
}.get(result['status'], Colors.RED)
status_text = {
'SUCCESS': 'OK',
'NO_PING': 'FLASHED, NO PING',
'FAILED': 'FAILED',
'TIMEOUT': 'TIMEOUT'
}.get(result['status'], 'ERROR')
print(f"{status_color}[Device {result['index']:2d}] {result['device']:14s}"
f"{result['ip']:15s} [{status_text}]{Colors.NC}")
f"{result['ip']:15s} [{result['status']}]{Colors.NC}")
def deploy_all(self):
"""Deploy to all devices (parallel or sequential)"""
if self.parallel:
self.deploy_all_parallel()
else:
self.deploy_all_sequential()
if self.parallel: self.deploy_all_parallel()
else: self.deploy_all_sequential()
def print_summary(self):
"""Print deployment summary"""
print()
print(f"{Colors.YELLOW}[4/4] Deployment Summary{Colors.NC}")
print(f"{Colors.BLUE}{'='*70}{Colors.NC}")
success = sum(1 for r in self.results.values() if r['status'] == 'SUCCESS')
failed = sum(1 for r in self.results.values() if r['status'] in ['FAILED', 'TIMEOUT'])
# Count statuses
success_count = sum(1 for r in self.results.values() if r['status'] == 'SUCCESS')
no_ping_count = sum(1 for r in self.results.values() if r['status'] == 'NO_PING')
failed_count = sum(1 for r in self.results.values() if r['status'] in ['FAILED', 'TIMEOUT'])
# Print all devices
for i in range(len(self.devices)):
if i in self.results:
result = self.results[i]
status_icon = {
'SUCCESS': f"{Colors.GREEN}{Colors.NC}",
'NO_PING': f"{Colors.YELLOW}{Colors.NC}",
'FAILED': f"{Colors.RED}{Colors.NC}",
'TIMEOUT': f"{Colors.RED}{Colors.NC}"
}.get(result['status'], f"{Colors.RED}?{Colors.NC}")
status_msg = {
'NO_PING': " (no ping response)",
'FAILED': " (failed)",
'TIMEOUT': " (timeout)"
}.get(result['status'], "")
print(f"{status_icon} {result['device']:14s}{result['ip']}{status_msg}")
r = self.results[i]
icon = f"{Colors.GREEN}{Colors.NC}" if r['status'] == 'SUCCESS' else f"{Colors.RED}{Colors.NC}"
print(f"{icon} {r['device']:14s}{r['ip']}")
print(f"{Colors.BLUE}{'='*70}{Colors.NC}")
print(f"Total: {len(self.devices)} devices")
print(f"{Colors.GREEN}Success: {success_count}{Colors.NC}")
if no_ping_count > 0:
print(f"{Colors.YELLOW}Warning: {no_ping_count} (flashed but no ping){Colors.NC}")
if failed_count > 0:
print(f"{Colors.RED}Failed: {failed_count}{Colors.NC}")
print(f"{Colors.BLUE}{'='*70}{Colors.NC}")
# Print log location
print()
print(f"Logs: /tmp/esp32_deploy_*.log")
# Print test commands
print()
print("Test commands:")
print(f" # Test first device")
print(f" iperf -c {self.get_ip_for_index(0)}")
print()
print(f" # Ping all devices")
ip_range = f"{self.ip_start}..{self.ip_start + len(self.devices) - 1}"
print(f" for i in {{{ip_range}}}; do ping -c 1 {self.ip_base}.$i & done; wait")
print()
return failed_count
print(f"Total: {len(self.devices)}")
print(f"Success: {success}")
print(f"Failed: {failed}")
return failed
def main():
parser = argparse.ArgumentParser(
description='ESP32 Mass Deployment Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Deploy to first 30 devices (default)
%(prog)s -s ClubHouse2G -p mypassword
# Deploy sequentially (easier debugging)
%(prog)s -s ClubHouse2G -p mypassword --sequential
# Deploy to all connected devices
%(prog)s -s ClubHouse2G -p mypassword -n 0
# Custom IP range
%(prog)s -s ClubHouse2G -p mypassword --start-ip 10.0.0.100
# From environment variables
export SSID="MyNetwork"
export PASSWORD="secret123"
export START_IP="192.168.1.51"
%(prog)s
# Verbose sequential mode (see everything)
%(prog)s -s ClubHouse2G -p mypassword --sequential -v
"""
)
parser.add_argument('-d', '--dir', default=os.getcwd(),
help='ESP-IDF project directory (default: current dir)')
parser.add_argument('-s', '--ssid',
default=os.environ.get('SSID', 'ClubHouse2G'),
help='WiFi SSID (default: ClubHouse2G or $SSID)')
parser.add_argument('-p', '--password',
default=os.environ.get('PASSWORD', ''),
help='WiFi password (default: $PASSWORD)')
parser.add_argument('--start-ip',
default=os.environ.get('START_IP', '192.168.1.51'),
help='Starting IP address (default: 192.168.1.51 or $START_IP)')
parser.add_argument('-n', '--num-devices', type=int, default=30,
help='Number of devices to deploy (default: 30, use 0 for all)')
parser.add_argument('-g', '--gateway',
default=os.environ.get('GATEWAY', '192.168.1.1'),
help='Gateway IP (default: 192.168.1.1 or $GATEWAY)')
parser.add_argument('-m', '--netmask',
default=os.environ.get('NETMASK', '255.255.255.0'),
help='Network mask (default: 255.255.255.0 or $NETMASK)')
parser.add_argument('-b', '--baud', type=int,
default=int(os.environ.get('BAUD_RATE', 460800)),
help='Baud rate for flashing (default: 460800 or $BAUD_RATE)')
parser.add_argument('-r', '--retries', type=int,
default=int(os.environ.get('MAX_RETRIES', 2)),
help='Max retries per device (default: 2 or $MAX_RETRIES)')
parser.add_argument('--no-verify', action='store_true',
help='Skip ping verification')
parser.add_argument('--sequential', action='store_true',
help='Flash devices sequentially instead of parallel')
parser.add_argument('-v', '--verbose', action='store_true',
help='Verbose output')
parser = argparse.ArgumentParser(description='ESP32 Mass Deployment Tool')
parser.add_argument('-d', '--dir', default=os.getcwd(), help='ESP-IDF project dir')
parser.add_argument('-s', '--ssid', help='WiFi SSID (Optional)')
parser.add_argument('-p', '--password', help='WiFi Password (Optional)')
parser.add_argument('--start-ip', default='192.168.1.51', help='Starting IP')
parser.add_argument('-n', '--num-devices', type=int, default=30, help='Max devices')
parser.add_argument('-g', '--gateway', default='192.168.1.1', help='Gateway IP')
parser.add_argument('-m', '--netmask', default='255.255.255.0', help='Netmask')
parser.add_argument('-b', '--baud', type=int, default=460800, help='Flash baud rate')
parser.add_argument('-r', '--retries', type=int, default=2, help='Retries')
parser.add_argument('--no-verify', action='store_true', help='Skip ping check')
parser.add_argument('--sequential', action='store_true', help='Run sequentially')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose')
args = parser.parse_args()
# Validate password
if not args.password:
print(f"{Colors.RED}ERROR: WiFi password not set!{Colors.NC}")
print()
print("Provide password via:")
print(" 1. Command line: -p 'your_password'")
print(" 2. Environment: export PASSWORD='your_password'")
print()
print("Example:")
print(f" {sys.argv[0]} -s MyWiFi -p mypassword")
# Validate arguments: Must have both SSID+Pass or neither
if (args.ssid and not args.password) or (args.password and not args.ssid):
print(f"{Colors.RED}ERROR: Provide both SSID and Password for config, or neither for flash-only.{Colors.NC}")
sys.exit(1)
# Create deployer
deployer = DeviceDeployer(
project_dir=args.dir,
ssid=args.ssid,
password=args.password,
start_ip=args.start_ip,
netmask=args.netmask,
gateway=args.gateway,
baud_rate=args.baud,
max_retries=args.retries,
verify_ping=not args.no_verify,
project_dir=args.dir, ssid=args.ssid, password=args.password,
start_ip=args.start_ip, netmask=args.netmask, gateway=args.gateway,
baud_rate=args.baud, max_retries=args.retries, verify_ping=not args.no_verify,
num_devices=args.num_devices if args.num_devices > 0 else None,
verbose=args.verbose,
parallel=not args.sequential
verbose=args.verbose, parallel=not args.sequential
)
# Run deployment
deployer.print_banner()
if not deployer.build_firmware():
sys.exit(1)
if not deployer.detect_devices():
sys.exit(1)
if not deployer.build_firmware(): sys.exit(1)
if not deployer.detect_devices(): sys.exit(1)
deployer.deploy_all()
failed_count = deployer.print_summary()
sys.exit(failed_count)
if __name__ == '__main__':