333 lines
14 KiB
Python
Executable File
333 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
ESP32 Mass Deployment Tool (Fixed for Parallel Flashing)
|
|
Uses esptool.py directly to bypass CMake locking issues.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import glob
|
|
import time
|
|
import argparse
|
|
import serial
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
class Colors:
|
|
RED = '\033[0;31m'
|
|
GREEN = '\033[0;32m'
|
|
YELLOW = '\033[1;33m'
|
|
BLUE = '\033[0;34m'
|
|
NC = '\033[0m'
|
|
|
|
class DeviceDeployer:
|
|
def __init__(self, project_dir, ssid, password, start_ip="192.168.1.51",
|
|
netmask="255.255.255.0", gateway="192.168.1.1",
|
|
baud_rate=460800, max_retries=2, verify_ping=True,
|
|
num_devices=None, verbose=False, parallel=True):
|
|
|
|
self.project_dir = Path(project_dir)
|
|
self.ssid = ssid
|
|
self.password = password
|
|
self.start_ip = start_ip
|
|
self.netmask = netmask
|
|
self.gateway = gateway
|
|
self.baud_rate = baud_rate
|
|
self.max_retries = max_retries
|
|
self.verify_ping = verify_ping
|
|
self.num_devices = num_devices
|
|
self.verbose = verbose
|
|
self.parallel = parallel
|
|
|
|
# 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()
|
|
print(f"{Colors.BLUE}{'='*70}")
|
|
print("ESP32 Mass Deployment Tool")
|
|
print(f"{'='*70}{Colors.NC}")
|
|
print(f"Project: {self.project_dir}")
|
|
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 firmware once to generate flash_args"""
|
|
print()
|
|
print(f"{Colors.YELLOW}[1/4] Building firmware...{Colors.NC}")
|
|
try:
|
|
# 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
|
|
)
|
|
|
|
# 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 "")
|
|
return False
|
|
|
|
def detect_devices(self):
|
|
print()
|
|
print(f"{Colors.YELLOW}[2/4] Detecting ESP32 devices...{Colors.NC}")
|
|
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}")
|
|
return False
|
|
|
|
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):
|
|
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):
|
|
return f"{self.ip_base}.{self.ip_start + index}"
|
|
|
|
def flash_and_configure(self, index, device):
|
|
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)
|
|
if self.verbose or not self.parallel:
|
|
print(f"[{index}] {msg}")
|
|
|
|
for attempt in range(1, self.max_retries + 1):
|
|
log(f"=== Device {index}: {device} (Attempt {attempt}/{self.max_retries}) ===")
|
|
|
|
# --- 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(
|
|
cmd,
|
|
cwd=self.project_dir, # Run from project dir so relative paths in flash_args work
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=300
|
|
)
|
|
log("✓ Flash successful")
|
|
except subprocess.CalledProcessError as e:
|
|
log(f"✗ Flash failed: {e.stderr.decode() if e.stderr else 'Unknown error'}")
|
|
if attempt == self.max_retries:
|
|
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("✗ 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': target_ip, 'status': 'TIMEOUT', 'log': log_lines}
|
|
continue
|
|
|
|
# --- CONFIGURATION ---
|
|
log("Waiting for boot (3s)...")
|
|
time.sleep(3)
|
|
|
|
if self.config_mode:
|
|
log(f"Configuring WiFi ({target_ip})...")
|
|
try:
|
|
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"
|
|
)
|
|
|
|
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:
|
|
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:
|
|
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)
|
|
|
|
# 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):
|
|
print()
|
|
print(f"{Colors.YELLOW}[3/4] Flashing (parallel)...{Colors.NC}")
|
|
|
|
# 10 workers is a safe limit for USB hubs
|
|
max_workers = min(10, len(self.devices))
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
futures = {
|
|
executor.submit(self.flash_and_configure, i, device): (i, device)
|
|
for i, device in enumerate(self.devices)
|
|
}
|
|
|
|
for future in as_completed(futures):
|
|
result = future.result()
|
|
self.results[result['index']] = result
|
|
self.print_device_status(result)
|
|
|
|
def deploy_all_sequential(self):
|
|
print()
|
|
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
|
|
self.print_device_status(result)
|
|
|
|
def print_device_status(self, result):
|
|
status_color = {
|
|
'SUCCESS': Colors.GREEN, 'NO_PING': Colors.YELLOW,
|
|
'FAILED': Colors.RED, 'TIMEOUT': Colors.RED
|
|
}.get(result['status'], Colors.RED)
|
|
|
|
print(f"{status_color}[Device {result['index']:2d}] {result['device']:14s} → "
|
|
f"{result['ip']:15s} [{result['status']}]{Colors.NC}")
|
|
|
|
def deploy_all(self):
|
|
if self.parallel: self.deploy_all_parallel()
|
|
else: self.deploy_all_sequential()
|
|
|
|
def print_summary(self):
|
|
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'])
|
|
|
|
for i in range(len(self.devices)):
|
|
if i in self.results:
|
|
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)}")
|
|
print(f"Success: {success}")
|
|
print(f"Failed: {failed}")
|
|
return failed
|
|
|
|
def main():
|
|
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 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)
|
|
|
|
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,
|
|
num_devices=args.num_devices if args.num_devices > 0 else None,
|
|
verbose=args.verbose, parallel=not args.sequential
|
|
)
|
|
|
|
deployer.print_banner()
|
|
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__':
|
|
main()
|