mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-09-24 09:07:39 +00:00
Layout, from on-device feedback: the magnetometer/heading-source line moved
from the stats column to a centred line over the dial; the stats panel now
starts at the top of the column, and the rows that freed up went to the
target -- name, range + bearing, how far to turn ("56 deg right", "ahead")
and when the contact was last heard. The key hint is centred along the
bottom edge of the view and spells the actions out ("C calibrate O rotate
F flip <> target"). The heading's DIGITS are centred with the degree sign
hanging off their right edge, so the number does not appear to shift as the
reading crosses 100/200; the width estimate also counts characters rather
than bytes now, which is what put it half a glyph off (the degree sign is
two bytes in UTF-8).
Host: label:width(px) takes an optional alignment ("center"/"right") -- an
app cannot measure glyphs, so this is the only way for it to centre a line
exactly. Also excluded the app ROOT from keyboard-nav focus: excluding only
the body moved the reverse-video highlight up one level instead of removing
it, which is why the page was still white.
sideload_app.py retries fput/fend as well as fadd -- the same UART byte loss
that garbles a long line can garble a short one ("Error: unknown command").
Calibration now reports the measured field strength in its toast, which is
the number that says whether the calibration is any good (Earth: 0.25-0.65 G).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
187 lines
7.2 KiB
Python
187 lines
7.2 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
|
|
|
|
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("--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)
|
|
|
|
s = open_port(args.port, args.baud)
|
|
try:
|
|
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()
|