mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-09-22 05:07:33 +00:00
198 lines
7.7 KiB
Python
198 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Sideload a Lua Store app (or any /apps or /lang file) onto a device over its
|
|
serial console.
|
|
|
|
Boards whose storage is soldered on (the ThinkNode M9) cannot take the
|
|
"drop it on the SD card" route from the SDK page, and the firmware's Store can
|
|
only download from firmware.wadamesh.com. This pushes the files through the
|
|
serial CLI instead, using three commands the firmware offers for exactly this:
|
|
|
|
fput /apps/<name> open (truncate) the file
|
|
fadd <off> <len> <sum> <base64> append a chunk; the device checks the
|
|
offset, decoded length and byte sum and
|
|
answers "Error: ..." so we can re-send
|
|
fend close it
|
|
|
|
Lines are kept under 128 bytes on purpose: that is the ESP32's UART hardware
|
|
FIFO, and it is all that buffers the console while the firmware is inside a
|
|
flash-cache pause (the UART interrupt is not IRAM-resident). Longer lines
|
|
lose their middle there; shorter ones just wait.
|
|
|
|
Usage:
|
|
scripts/sideload_app.py --port /dev/cu.wchusbserial10 deploy/apps/gpscompass/1.0
|
|
scripts/sideload_app.py --port ... --reboot deploy/apps/gpscompass/1.0/gpscompass.lua
|
|
scripts/sideload_app.py --port ... --dest /lang deploy/apps/lang/11/de.lang
|
|
scripts/sideload_app.py --port ... --remote /apps/audio_test.d/test.mp3 ~/Music/test.mp3
|
|
|
|
Pass an app VERSION directory to send its <id>.lua + <id>.json, or individual
|
|
files. --reboot restarts the device afterwards so the drawer/Store rescan
|
|
picks the new app up (opening the Store page also rescans).
|
|
|
|
Needs pyserial: python3 -m pip install pyserial
|
|
(or: uvx --from platformio --with pyserial python scripts/sideload_app.py ...)
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
try:
|
|
import serial
|
|
except ImportError:
|
|
sys.exit("Missing dependency: pyserial (python3 -m pip install pyserial)")
|
|
|
|
CHUNK = 72 # bytes per fadd line -> 96 base64 chars; whole line < 128 bytes
|
|
RETRIES = 6 # per chunk, on an "Error:" reply or a silent (lost) line
|
|
|
|
|
|
def open_port(port, baud):
|
|
s = serial.Serial()
|
|
s.port, s.baudrate, s.timeout = port, baud, 0.02
|
|
s.dtr = False
|
|
s.rts = False # try not to reset the board; some bridges do it anyway
|
|
s.open()
|
|
# The CH34x bridge on the M9 resets the board on open regardless of DTR/RTS.
|
|
# If a boot log starts streaming, wait for the UI to come up ("[BOOT] ui
|
|
# ready" — the console is not serviced before that, and the UI init has
|
|
# multi-second quiet gaps that a plain silence wait mistakes for "done").
|
|
# No output within 2 s = the board did not reset and is ready now.
|
|
t0, seen = time.time(), b""
|
|
while time.time() - t0 < 40:
|
|
chunk = s.read(4096)
|
|
if chunk:
|
|
seen += chunk
|
|
if b"[BOOT] ui ready" in seen:
|
|
time.sleep(1.5)
|
|
break
|
|
elif not seen and time.time() - t0 > 2.0:
|
|
break
|
|
s.reset_input_buffer()
|
|
return s
|
|
|
|
|
|
class DeviceError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def command(s, line, expect, timeout=4.0): # the loop stalls for ~0.5 s on GPS work
|
|
"""Send one CLI line, return the reply line that starts with `expect`.
|
|
The console echoes what it receives, so skip the echo and any log noise.
|
|
Raises DeviceError on an "Error:" reply, TimeoutError when nothing came."""
|
|
s.write((line + "\n").encode())
|
|
s.flush()
|
|
buf = b""
|
|
t0 = time.time()
|
|
while time.time() - t0 < timeout:
|
|
buf += s.read(4096)
|
|
for raw in buf.split(b"\n"):
|
|
text = raw.decode("utf-8", "replace").strip("\r ")
|
|
if text.startswith(expect):
|
|
return text
|
|
if text.startswith("Error:"):
|
|
raise DeviceError(text)
|
|
raise TimeoutError("no reply to: %s" % line[:40])
|
|
|
|
|
|
def command_retry(s, line, expect):
|
|
"""fput/fend: short lines, but the same byte loss can garble them — a
|
|
mangled line answers "Error: unknown command" or nothing at all."""
|
|
for attempt in range(RETRIES):
|
|
try:
|
|
return command(s, line, expect)
|
|
except (DeviceError, TimeoutError) as e:
|
|
if isinstance(e, DeviceError) and "unknown command" not in str(e):
|
|
raise
|
|
s.write(b"\n")
|
|
time.sleep(0.2)
|
|
raise RuntimeError("no usable reply to: " + line)
|
|
|
|
|
|
def push(s, local_path, remote_path):
|
|
data = open(local_path, "rb").read()
|
|
command_retry(s, "fput " + remote_path, "ok fput")
|
|
off, retries = 0, 0
|
|
while off < len(data):
|
|
piece = data[off:off + CHUNK]
|
|
line = "fadd %d %d %d %s" % (off, len(piece), sum(piece) & 0xFFFF, base64.b64encode(piece).decode())
|
|
try:
|
|
reply = command(s, line, "ok fadd")
|
|
except DeviceError as e:
|
|
msg = str(e)
|
|
if msg.startswith("Error: offset "):
|
|
off = int(msg.split()[-1]) # device tells us where it really is
|
|
retries += 1
|
|
if retries > RETRIES:
|
|
raise RuntimeError("giving up at offset %d: %s" % (off, msg))
|
|
continue
|
|
except TimeoutError:
|
|
# a lost line leaves the device waiting for its terminator; send one
|
|
# (an empty line is ignored by the console) and ask where it is
|
|
s.write(b"\n")
|
|
retries += 1
|
|
if retries > RETRIES:
|
|
raise RuntimeError("no reply from the device at offset %d" % off)
|
|
continue
|
|
retries = 0
|
|
off = int(reply.split()[-1])
|
|
if sys.stdout.isatty():
|
|
sys.stdout.write("\r %s: %d / %d bytes" % (remote_path, off, len(data)))
|
|
sys.stdout.flush()
|
|
reply = command_retry(s, "fend", "ok fend")
|
|
total = int(reply.split()[2])
|
|
if sys.stdout.isatty():
|
|
print()
|
|
if total != len(data):
|
|
raise RuntimeError("device wrote %d bytes, expected %d" % (total, len(data)))
|
|
print(" done: %s (%d bytes)" % (remote_path, total))
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("paths", nargs="+", help="app version directory, or individual files")
|
|
ap.add_argument("--port", required=True)
|
|
ap.add_argument("--baud", type=int, default=115200)
|
|
ap.add_argument("--dest", default="/apps", help="/apps (default) or /lang")
|
|
ap.add_argument("--remote", help="exact remote path for one file, including /apps/<id>.d/<name>")
|
|
ap.add_argument("--reboot", action="store_true", help="reboot the device afterwards")
|
|
args = ap.parse_args()
|
|
|
|
files = []
|
|
for p in args.paths:
|
|
if os.path.isdir(p):
|
|
app_id = os.path.basename(os.path.dirname(os.path.abspath(p).rstrip("/")))
|
|
for ext in ("lua", "json"):
|
|
f = os.path.join(p, "%s.%s" % (app_id, ext))
|
|
if os.path.isfile(f):
|
|
files.append(f)
|
|
if not files:
|
|
sys.exit("no %s.lua in %s (pass deploy/apps/<id>/<ver>)" % (app_id, p))
|
|
elif os.path.isfile(p):
|
|
files.append(p)
|
|
else:
|
|
sys.exit("not found: " + p)
|
|
|
|
if args.remote:
|
|
if len(files) != 1:
|
|
sys.exit("--remote requires exactly one input file")
|
|
if not args.remote.startswith(("/apps/", "/lang/")):
|
|
sys.exit("--remote must begin with /apps/ or /lang/")
|
|
|
|
s = open_port(args.port, args.baud)
|
|
try:
|
|
if args.remote:
|
|
push(s, files[0], args.remote)
|
|
else:
|
|
for f in files:
|
|
push(s, f, "%s/%s" % (args.dest.rstrip("/"), os.path.basename(f)))
|
|
if args.reboot:
|
|
s.write(b"reboot\n")
|
|
s.flush()
|
|
print("rebooting the device")
|
|
finally:
|
|
s.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|