feat: Add v0.3 OTA updates — dual partition, esp-ota tool, rollback
Dual OTA partition table (ota_0/ota_1, 1920 KB each) on 4MB flash. Firmware gains OTA command, LED_OTA double-blink, version in STATUS, and automatic rollback validation. Pi-side esp-ota tool serves firmware via HTTP and orchestrates the update flow. esp-fleet gains ota subcommand.
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
"""Query all ESP32 CSI sensors in parallel."""
|
||||
|
||||
import concurrent.futures
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
DEFAULT_PORT = 5501
|
||||
@@ -14,6 +16,8 @@ SENSORS = [
|
||||
("hollow-acorn", "hollow-acorn.local"),
|
||||
]
|
||||
|
||||
ESP_OTA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "esp-ota")
|
||||
|
||||
USAGE = """\
|
||||
Usage: esp-fleet <command> [args...]
|
||||
|
||||
@@ -25,11 +29,14 @@ Commands:
|
||||
rate <10-100> Set ping rate on all devices
|
||||
power <2-20> Set TX power on all devices
|
||||
reboot Reboot all devices
|
||||
ota [firmware.bin] OTA update all devices (sequentially)
|
||||
|
||||
Examples:
|
||||
esp-fleet status
|
||||
esp-fleet identify
|
||||
esp-fleet rate 50"""
|
||||
esp-fleet rate 50
|
||||
esp-fleet ota
|
||||
esp-fleet ota /path/to/firmware.bin"""
|
||||
|
||||
|
||||
def query(name, host, cmd):
|
||||
@@ -54,11 +61,33 @@ def query(name, host, cmd):
|
||||
sock.close()
|
||||
|
||||
|
||||
def run_ota(firmware=None):
|
||||
"""Run OTA on each sensor sequentially."""
|
||||
for name, host in SENSORS:
|
||||
print(f"\n{'='*40}")
|
||||
print(f"OTA: {name} ({host})")
|
||||
print(f"{'='*40}")
|
||||
cmd = [ESP_OTA, host]
|
||||
if firmware:
|
||||
cmd += ["-f", firmware]
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
print(f"ERR: OTA failed for {name}, stopping fleet OTA", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"\nAll {len(SENSORS)} devices updated.")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
||||
print(USAGE)
|
||||
sys.exit(0 if sys.argv[1:] and sys.argv[1] in ("-h", "--help") else 2)
|
||||
|
||||
# Handle OTA subcommand separately (sequential, not parallel)
|
||||
if sys.argv[1].lower() == "ota":
|
||||
firmware = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
run_ota(firmware)
|
||||
return
|
||||
|
||||
cmd = " ".join(sys.argv[1:]).strip().upper()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(SENSORS)) as pool:
|
||||
|
||||
149
tools/esp-ota
Executable file
149
tools/esp-ota
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OTA firmware update for ESP32 CSI devices."""
|
||||
|
||||
import argparse
|
||||
import http.server
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
DEFAULT_CMD_PORT = 5501
|
||||
DEFAULT_HTTP_PORT = 8070
|
||||
DEFAULT_FW = os.path.expanduser(
|
||||
"~/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.bin"
|
||||
)
|
||||
TIMEOUT = 2.0
|
||||
REBOOT_WAIT = 25.0
|
||||
STATUS_RETRIES = 10
|
||||
STATUS_INTERVAL = 3.0
|
||||
|
||||
|
||||
def resolve(host: str) -> str:
|
||||
"""Resolve hostname to IP address."""
|
||||
try:
|
||||
result = socket.getaddrinfo(host, DEFAULT_CMD_PORT, socket.AF_INET, socket.SOCK_DGRAM)
|
||||
return result[0][4][0]
|
||||
except socket.gaierror as e:
|
||||
print(f"ERR: cannot resolve {host}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def udp_cmd(ip: str, cmd: str, timeout: float = TIMEOUT) -> str:
|
||||
"""Send UDP command and return reply."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.sendto(cmd.encode(), (ip, DEFAULT_CMD_PORT))
|
||||
data, _ = sock.recvfrom(512)
|
||||
return data.decode().strip()
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def get_local_ip(target_ip: str) -> str:
|
||||
"""Determine which local IP the target can reach us on."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect((target_ip, 80))
|
||||
return sock.getsockname()[0]
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def serve_firmware(directory: str, port: int) -> http.server.HTTPServer:
|
||||
"""Start HTTP server serving firmware directory in a background thread."""
|
||||
handler = lambda *a, **k: http.server.SimpleHTTPRequestHandler(
|
||||
*a, directory=directory, **k
|
||||
)
|
||||
server = http.server.HTTPServer(("0.0.0.0", port), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OTA firmware update for ESP32 CSI devices")
|
||||
parser.add_argument("host", help="Device hostname or IP (e.g., amber-maple.local)")
|
||||
parser.add_argument("-f", "--firmware", default=DEFAULT_FW, help="Path to firmware .bin")
|
||||
parser.add_argument("-p", "--port", type=int, default=DEFAULT_HTTP_PORT, help="HTTP server port")
|
||||
parser.add_argument("--no-wait", action="store_true", help="Don't wait for reboot verification")
|
||||
args = parser.parse_args()
|
||||
|
||||
fw_path = os.path.abspath(args.firmware)
|
||||
if not os.path.isfile(fw_path):
|
||||
print(f"ERR: firmware not found: {fw_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
fw_size = os.path.getsize(fw_path)
|
||||
fw_dir = os.path.dirname(fw_path)
|
||||
fw_name = os.path.basename(fw_path)
|
||||
|
||||
print(f"Firmware: {fw_path} ({fw_size // 1024} KB)")
|
||||
|
||||
# Resolve device
|
||||
ip = resolve(args.host)
|
||||
print(f"Device: {args.host} ({ip})")
|
||||
|
||||
# Check device is alive
|
||||
try:
|
||||
reply = udp_cmd(ip, "STATUS")
|
||||
print(f"Status: {reply}")
|
||||
except (socket.timeout, OSError) as e:
|
||||
print(f"ERR: device not responding: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Determine local IP
|
||||
local_ip = get_local_ip(ip)
|
||||
ota_url = f"http://{local_ip}:{args.port}/{fw_name}"
|
||||
print(f"OTA URL: {ota_url}")
|
||||
|
||||
# Start HTTP server
|
||||
server = serve_firmware(fw_dir, args.port)
|
||||
print(f"HTTP server on port {args.port}")
|
||||
|
||||
# Send OTA command
|
||||
try:
|
||||
reply = udp_cmd(ip, f"OTA {ota_url}")
|
||||
print(f"OTA cmd: {reply}")
|
||||
except (socket.timeout, OSError) as e:
|
||||
server.shutdown()
|
||||
print(f"ERR: OTA command failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not reply.startswith("OK"):
|
||||
server.shutdown()
|
||||
print(f"ERR: device rejected OTA: {reply}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.no_wait:
|
||||
print("OTA started (--no-wait, not verifying)")
|
||||
# Keep server alive briefly for download
|
||||
time.sleep(30)
|
||||
server.shutdown()
|
||||
return
|
||||
|
||||
# Wait for device to download, flash, and reboot
|
||||
print(f"Waiting for reboot...")
|
||||
time.sleep(REBOOT_WAIT)
|
||||
|
||||
# Verify device comes back
|
||||
for attempt in range(1, STATUS_RETRIES + 1):
|
||||
try:
|
||||
ip = resolve(args.host)
|
||||
reply = udp_cmd(ip, "STATUS", timeout=3.0)
|
||||
print(f"Verified: {reply}")
|
||||
server.shutdown()
|
||||
return
|
||||
except (socket.timeout, OSError):
|
||||
print(f" retry {attempt}/{STATUS_RETRIES}...")
|
||||
time.sleep(STATUS_INTERVAL)
|
||||
|
||||
server.shutdown()
|
||||
print("ERR: device did not come back after OTA", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user