feat: ota_push.py operator firmware update script
This commit is contained in:
103
tools/ota_push.py
Executable file
103
tools/ota_push.py
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ota_push.py — Push firmware OTA to a TimerCamera-F device via Arduino OTA.
|
||||
|
||||
Requires: Python 3.8+, no extra pip packages needed
|
||||
Device must be connected to WiFi and reachable on the local network.
|
||||
|
||||
Usage:
|
||||
python ota_push.py \\
|
||||
--host dc-0042.local \\
|
||||
--firmware firmware/.pio/build/timercam/firmware.bin
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
OTA_PORT = 3232
|
||||
|
||||
|
||||
def compute_md5(path: str) -> str:
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def resolve_host(host: str, timeout: float = 10.0) -> str:
|
||||
"""Resolve hostname to IP, retrying until timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
last_err = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
return socket.gethostbyname(host)
|
||||
except socket.gaierror as e:
|
||||
last_err = e
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"Could not resolve {host!r} within {timeout:.0f}s: {last_err}")
|
||||
|
||||
|
||||
def push_ota(host: str, firmware_path: str) -> None:
|
||||
size = os.path.getsize(firmware_path)
|
||||
md5 = compute_md5(firmware_path)
|
||||
|
||||
print(f"Resolving {host} ...")
|
||||
ip = resolve_host(host)
|
||||
print(f" → {ip}")
|
||||
|
||||
print(f"Connecting to {ip}:{OTA_PORT} ...")
|
||||
with socket.create_connection((ip, OTA_PORT), timeout=15) as sock:
|
||||
# Arduino OTA handshake: "0:<size>:<md5>\n"
|
||||
header = f"0:{size}:{md5}\n".encode()
|
||||
sock.sendall(header)
|
||||
|
||||
sock.settimeout(10)
|
||||
resp = sock.recv(64).decode(errors="replace").strip()
|
||||
if resp != "OK":
|
||||
raise RuntimeError(f"OTA handshake rejected: {resp!r}")
|
||||
|
||||
print(f"Sending {size:,} bytes ...")
|
||||
sent = 0
|
||||
with open(firmware_path, "rb") as f:
|
||||
while chunk := f.read(4096):
|
||||
sock.sendall(chunk)
|
||||
sent += len(chunk)
|
||||
pct = sent * 100 // size
|
||||
print(f"\r {pct:3d}% [{sent:,}/{size:,}]", end="", flush=True)
|
||||
print()
|
||||
|
||||
sock.settimeout(30)
|
||||
final = sock.recv(64).decode(errors="replace").strip()
|
||||
if final != "OK":
|
||||
raise RuntimeError(f"OTA write failed: {final!r}")
|
||||
|
||||
print(f"✓ OTA complete — {host} is rebooting")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Push OTA firmware update to a door counter device")
|
||||
parser.add_argument("--host", required=True,
|
||||
help="mDNS hostname or IP, e.g. dc-0042.local")
|
||||
parser.add_argument("--firmware", required=True,
|
||||
help="Path to .bin firmware file")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.firmware):
|
||||
print(f"Error: firmware file not found: {args.firmware}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
push_ota(args.host, args.firmware)
|
||||
except (RuntimeError, OSError) as e:
|
||||
print(f"\nError: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user