mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
feat(agents): add the run-meshtastic-android skill with desktop and emulator drivers (#6955)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80849ca503
commit
38d4fde160
3 files changed
+692
No files matched your search
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: run-meshtastic-android
|
||||
description: Run, launch, drive, and screenshot the Meshtastic app — the Compose Desktop app via hot reload (semantic clicks, live reload, window screenshots) or the Android app on an emulator (scripted deeplink bring-up, uiautomator taps, screencap). Use when asked to run the app, verify a UI change in the real app, take a screenshot, or exercise a flow end to end against a simulated radio.
|
||||
---
|
||||
|
||||
# Run Meshtastic (Desktop & Emulator)
|
||||
|
||||
Two binaries, two drivers, one simulated radio. All paths are relative to the repo
|
||||
root. Both drivers are Python 3, stdlib only, and print `--- <cmd> done ---` per
|
||||
step on stderr.
|
||||
|
||||
- **Desktop** (`:desktopApp`, Compose/JVM, runs on this machine): launch with the
|
||||
hot-reload run task, drive through `.claude/skills/run-meshtastic-android/driver.py`,
|
||||
which speaks MCP JSON-RPC to `:desktopApp:hotMcpServer` — semantic tree, clicks
|
||||
by node id, `reload` (recompile + hot-swap), window screenshots.
|
||||
- **Emulator** (`:androidApp` fdroid debug): drive through
|
||||
`.claude/skills/run-meshtastic-android/driver_emulator.py` — scripted deeplink
|
||||
bring-up, uiautomator-based taps, screencap.
|
||||
- **Radio**: neither app does much without one. `mcp__meshtastic__replay_start`
|
||||
(meshtastic MCP) serves a simulated Meshtastic TCP radio; the desktop app reaches
|
||||
it at `127.0.0.1:<port>`, an AVD at `10.0.2.2:<port>`. One client per session —
|
||||
run the desktop and emulator against **different ports** (e.g. 4403 and 4404).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Gradle runs go through the machine-wide queue: `~/.claude/bin/gradle-queue`.
|
||||
Everything after its `--` is **Gradle arguments** — it runs `./gradlew` itself
|
||||
(`gradle-queue -- ./gradlew tasks` fails with `Task './gradlew' not found`).
|
||||
- The JetBrains 25 JDK Gradle provisioned at
|
||||
`~/.gradle/jdks/jetbrains_s_r_o_-25-*/…/Contents/Home` (the drivers find it themselves).
|
||||
- Emulator leg: a running AVD (`adb devices`) with the fdroid debug build installed
|
||||
(`./gradlew :androidApp:installFdroidDebug` via the queue if missing).
|
||||
- A simulated radio, e.g. `replay_start(source="meshcon", sim_nodes=30, port=4403,
|
||||
rate=2, loop=true, sim_profile={"traceroute_pairs_per_hour": 0})` — mute the
|
||||
traceroutes or their modals bury whatever you are testing.
|
||||
|
||||
## Run: Desktop (agent path)
|
||||
|
||||
Kill stray instances first — two apps fight over the pid file and the MCP server
|
||||
reports `connected:false` forever:
|
||||
|
||||
```bash
|
||||
pgrep -fl "MainKt|devtools.Main" # kill any hits before launching
|
||||
```
|
||||
|
||||
Launch (the Nix dev shell's Darwin stdenv breaks the MapLibre FFI — strip it):
|
||||
|
||||
```bash
|
||||
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
|
||||
JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
|
||||
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
|
||||
~/.claude/bin/gradle-queue -- :desktopApp:hotRunAsync
|
||||
```
|
||||
|
||||
`BUILD SUCCESSFUL` + `desktopApp/build/run/main/main.pid` on disk means the app is up.
|
||||
|
||||
Drive it. Each driver invocation spawns a fresh `hotMcpServer`, auto-waits for it to
|
||||
attach (asynchronous — the driver polls `status` for you), runs the commands in
|
||||
order, and exits:
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/run-meshtastic-android/driver.py tree # semantic tree (JSON, node ids)
|
||||
python3 .claude/skills/run-meshtastic-android/driver.py click=170 sleep=1.5 tree
|
||||
python3 .claude/skills/run-meshtastic-android/driver.py raise ss=/tmp/app.png
|
||||
python3 .claude/skills/run-meshtastic-android/driver.py reload # recompile + hot-swap edits
|
||||
```
|
||||
|
||||
Run `driver.py` with no arguments for the full command list (`type=NODEID:TEXT`,
|
||||
`scroll_to=NODEID:IDX`, `restart`, `err`, `logs`, …). `tools` prints the server's
|
||||
live tool schemas if they've drifted.
|
||||
|
||||
**Verified flow** (connect to a sim and see its mesh): nav-rail tabs are semantic
|
||||
`Tab` nodes — `Connect` opened via `click=<its id from tree>`, then the `Network`
|
||||
radio button, then the device row for `127.0.0.1` under Recent Network Devices.
|
||||
The sim's `replay_status` flips to `connected:true` within seconds and the Nodes
|
||||
tab fills with the sim's mesh (`RPLY Replay Observer`, …).
|
||||
|
||||
The connection card can sit on "Reconnecting…" while packets already flow — the
|
||||
label lags the config download. Trust `replay_status` and the Nodes list, not the
|
||||
card text.
|
||||
|
||||
**Screenshots capture the window's on-screen region**, so the window must be
|
||||
frontmost: always `raise` before `ss`. If `ss` shows your terminal, that's why.
|
||||
|
||||
`hotMcpServer` and `reload` compile **outside** gradle-queue (a long-lived stdio
|
||||
server can't hold a slot) — check `~/.claude/bin/gradle-queue --status` before a
|
||||
`reload` if other sessions may be building, and keep those runs short.
|
||||
|
||||
### Desktop deeplink launch (no clicking — but no hot reload)
|
||||
|
||||
The desktop app parses the same Meshtastic deeplink URIs from its **program args**
|
||||
(`Main.kt` accepts `meshtastic://` and `https://meshtastic.org/...`), so a connected
|
||||
app is one command:
|
||||
|
||||
```bash
|
||||
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
|
||||
JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
|
||||
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
|
||||
~/.claude/bin/gradle-queue -- :desktopApp:run --args="https://meshtastic.org/connections?address=t127.0.0.1:4403"
|
||||
```
|
||||
|
||||
Verified against a sim the app had never connected to before, so it is the deeplink
|
||||
acting, not last-device auto-reconnect. Caveats, all observed:
|
||||
|
||||
- `hotRunAsync` does **not** accept `--args` (its option list: --auto, --className,
|
||||
--funName, --mainClass, --stdout/--stderr only) — deeplink launch means the plain
|
||||
`run` task, which trades away hot reload. Long driving session → `hotRunAsync` +
|
||||
the driver's click path; quick "get me a connected app" → `run --args=…`.
|
||||
- `run` blocks, so it **holds a gradle-queue slot for the app's whole lifetime**.
|
||||
Keep such runs short, or other sessions' builds will queue behind your app.
|
||||
- The deeplink races last-device auto-reconnect: the app can connect to its
|
||||
remembered device first, then switch to the deeplink's target a moment later —
|
||||
if the remembered device is another sim, that sim briefly shows a client too.
|
||||
- No trust dialog blocked the localhost connect in testing (unlike the Android
|
||||
build, which pops one for a never-seen device).
|
||||
|
||||
## Run: Emulator (agent path)
|
||||
|
||||
Scripted bring-up only — never hand-walk onboarding or the manual-IP dialog:
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/run-meshtastic-android/driver_emulator.py -s emulator-5554 \
|
||||
connect=t10.0.2.2:4404 wait_text=RPLY ss=/tmp/emu.png
|
||||
```
|
||||
|
||||
`connect` force-stops the app, relaunches `org.meshtastic.app.MainActivity` with the
|
||||
debug-only `skip_onboarding` extra and the `/connections?address=` deeplink
|
||||
(`t` = TCP, `x` = BLE, `s` = serial, `n` = disconnect — full path list in
|
||||
`docs/en/developer/navigation-and-deep-links.md`), then waits for the trust dialog
|
||||
newer builds pop and taps its **Connect** button. Success looks like the Connection
|
||||
screen showing `RPLY Replay Observer` with a **Disconnect** button, and
|
||||
`replay_status` reporting `connected:true`.
|
||||
|
||||
Other commands: `dump`, `find=TEXT`, `tap_text=TEXT`, `tap=X,Y`, `text=`, `key=`,
|
||||
`swipe=`, `launch`, `stop` — run with no arguments for the list. Default package is
|
||||
`com.geeksville.mesh.fdroid.debug` (`-p` to override).
|
||||
|
||||
## Run (human path)
|
||||
|
||||
`./gradlew :desktopApp:run` (via the queue, same env hygiene) opens the window
|
||||
without hot reload; Ctrl-C to stop. The emulator app is just the launcher icon —
|
||||
but a debug build launched by icon lands on onboarding; the deeplink path above is
|
||||
faster even for humans.
|
||||
|
||||
## Stopping
|
||||
|
||||
- Desktop: take the pid from the app's own pid file — it is a Java properties
|
||||
file (not a bare pid) and self-deletes on clean exit:
|
||||
|
||||
```bash
|
||||
kill $(sed -n 's/^pid=//p' desktopApp/build/run/main/main.pid)
|
||||
```
|
||||
|
||||
If the pid file is gone but a process lingers, `pgrep -af "MainKt|devtools.Main"`,
|
||||
check each match's path for **this** checkout, and kill that specific PID — a bare
|
||||
`pkill` on the pattern can take down another checkout's or session's app.
|
||||
- Emulator: `driver_emulator.py -s <serial> stop`.
|
||||
- Sim: `replay_stop`. Sessions the sim created are real user data in the app's DB;
|
||||
the app's last-selected device is now the sim — switch back on the Connect screen
|
||||
if a real radio should reconnect.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`gradle-queue -- ./gradlew …` fails**: args after `--` go to `./gradlew`,
|
||||
which the wrapper runs itself. And piping its output (`| tail`) eats the exit
|
||||
code — check for `BUILD SUCCESSFUL` in the text, not `$?`.
|
||||
- **`tap_text` matches substrings**: bare `Connect` also matches "Stop
|
||||
**Connect**ing" and "Re**connect**ing…". The driver tries exact text first;
|
||||
wait on the trust dialog's title ("Connect to this device"), not its button.
|
||||
- **The MCP server attaches asynchronously** — a `tree` fired immediately after
|
||||
spawn returns "No application is currently connected". The driver auto-waits;
|
||||
if it times out, the app isn't running (or a stray instance holds the pid file).
|
||||
- **`take_screenshot` needs the window visible** — `raise` first (System Events
|
||||
`AXRaise` targeting the window literally named "Meshtastic Desktop"; with two
|
||||
java processes, pid-based frontmosting picks the wrong one).
|
||||
- **One client per simulated node.** Two apps pointed at the same sim don't
|
||||
queue — they fight, stealing the connection back and forth so both flap
|
||||
between Connected and Reconnecting. The desktop app holding port 4403 means
|
||||
the emulator needs its own `replay_start` on 4404.
|
||||
- **`adb shell input text` can leave a trailing space**; dialogs' Add buttons
|
||||
silently no-op on it. And don't press BACK to dismiss the keyboard — it closes
|
||||
the dialog.
|
||||
- **Swipe near x≈30** in lists; mid-screen swipes get eaten by embedded maps.
|
||||
Never busy-loop adb — pace with `adb shell sleep 2` or the emulator drops offline.
|
||||
- The desktop app auto-reconnects to its last device on launch — it may already
|
||||
be connected to a real radio when you attach; check the Connect screen before
|
||||
assuming the sim.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Task './gradlew' not found in root project` → you passed `./gradlew` after
|
||||
`gradle-queue --`; drop it.
|
||||
- `BUILD FAILED in 1s` from `hotRunAsync` with slots free → read the full output;
|
||||
the queue wrapper's exit code vanishes behind pipes.
|
||||
- Screenshot is your terminal → `raise` before `ss` (window wasn't frontmost).
|
||||
- `connected:false` forever from `status` → stray `MainKt` from another checkout
|
||||
or worktree; `pgrep -fl MainKt`, kill, relaunch.
|
||||
- Trust dialog never tapped, app stuck on dialog → older driver matched
|
||||
"Reconnecting…"; re-run `tap_text=Connect` (exact match wins now).
|
||||
- UI card stuck "Reconnecting…" but sim says `connected:true` → not stuck; config
|
||||
download in progress. Check the Nodes tab for the sim's nodes.
|
||||
- Connection flapping → `desktopApp/build/run/main/hotRun.stderr.txt` carries the
|
||||
transport-level story ("Handshake stall detected at Stage 1 … requesting forced
|
||||
transport restart" is the app self-recovering, not a crash). Also check that a
|
||||
second app isn't fighting for the same sim (one client per simulated node).
|
||||
- A bare `status` right after spawn can report `connected:false` while the app is
|
||||
fine — the server attach is asynchronous; `wait` (or any UI command, which
|
||||
auto-waits) is the truth.
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive the running Meshtastic desktop app through the compose-hot-reload MCP server.
|
||||
|
||||
Speaks MCP JSON-RPC over the stdio of `./gradlew :desktopApp:hotMcpServer` (the same
|
||||
server android/.mcp.json registers), so it works with no MCP client attached at all.
|
||||
The app itself must already be running — launch it with :desktopApp:hotRunAsync first
|
||||
(see SKILL.md). Each invocation spawns the server, waits for it to attach to the app,
|
||||
executes the given commands in order, and exits.
|
||||
|
||||
Usage:
|
||||
driver.py [--repo DIR] CMD [CMD ...]
|
||||
|
||||
Commands (executed left to right):
|
||||
tools list the server's tools and their input schemas
|
||||
status print connection status
|
||||
wait poll status until "connected":true (120 s timeout)
|
||||
windows list app windows
|
||||
tree print the semantic tree (all windows)
|
||||
tree=SUBSTR print only tree lines whose text matches SUBSTR (case-insensitive)
|
||||
click=NODEID click a node by id from the tree
|
||||
longclick=NODEID long-click a node
|
||||
type=NODEID:TEXT set the text content of an editable node
|
||||
scroll_to=NODEID:IDX scroll item IDX of scrollable container NODEID into view
|
||||
ss=PATH.png screenshot the app window to PATH (absolute path)
|
||||
reload recompile + hot-swap current sources into the running app
|
||||
restart relaunch the app process (needed for singleton/init state)
|
||||
reset_ui reset the UI to its entry point
|
||||
raise bring the app window frontmost (required before ss —
|
||||
the screenshot captures the on-screen region)
|
||||
err print the current UI error, if any
|
||||
logs print recent app logs
|
||||
sleep=SECONDS pause between commands (animations, connection settling)
|
||||
|
||||
Example — poke the Connections screen and screenshot it:
|
||||
driver.py wait tree=Connections click=42 sleep=1 ss=/tmp/conn.png
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
NIX_POISON = ["DEVELOPER_DIR", "SDKROOT", "CC", "CXX", "LD", "AR", "NM", "RANLIB", "STRIP", "NIX_CC"]
|
||||
JDK_GLOB = os.path.expanduser("~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home")
|
||||
|
||||
|
||||
def clean_env():
|
||||
"""The Nix dev shell's Darwin stdenv breaks the MapLibre FFI and Skiko; strip it."""
|
||||
env = {k: v for k, v in os.environ.items() if k not in NIX_POISON}
|
||||
env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
import glob
|
||||
|
||||
jdks = sorted(glob.glob(JDK_GLOB))
|
||||
if jdks:
|
||||
env["JAVA_HOME"] = jdks[-1]
|
||||
return env
|
||||
|
||||
|
||||
class HotMcp:
|
||||
def __init__(self, repo):
|
||||
self.proc = subprocess.Popen(
|
||||
["./gradlew", "--no-daemon", "--quiet", "--console=plain", ":desktopApp:hotMcpServer"],
|
||||
cwd=repo,
|
||||
env=clean_env(),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
stdin, stdout = self.proc.stdin, self.proc.stdout
|
||||
assert stdin is not None and stdout is not None
|
||||
self.stdin, self.stdout = stdin, stdout
|
||||
# readline() would block past any deadline if the server keeps stdout open without
|
||||
# writing; a pump thread + queue makes the RPC timeout real.
|
||||
self._lines: "queue.Queue[str | None]" = queue.Queue()
|
||||
|
||||
def _pump(out, q):
|
||||
for line in out:
|
||||
q.put(line)
|
||||
q.put(None)
|
||||
|
||||
threading.Thread(target=_pump, args=(self.stdout, self._lines), daemon=True).start()
|
||||
self.next_id = 1
|
||||
self._rpc("initialize", {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "run-meshtastic-android-driver", "version": "1"},
|
||||
})
|
||||
self._notify("notifications/initialized")
|
||||
|
||||
def _send(self, obj):
|
||||
self.stdin.write(json.dumps(obj) + "\n")
|
||||
self.stdin.flush()
|
||||
|
||||
def _notify(self, method):
|
||||
self._send({"jsonrpc": "2.0", "method": method})
|
||||
|
||||
def _rpc(self, method, params, timeout=180):
|
||||
rid = self.next_id
|
||||
self.next_id += 1
|
||||
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
line = self._lines.get(timeout=remaining)
|
||||
except queue.Empty:
|
||||
break
|
||||
if line is None:
|
||||
raise RuntimeError("hotMcpServer closed its stdout (is another instance running?)")
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue # gradle noise
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if msg.get("id") == rid:
|
||||
if "error" in msg:
|
||||
raise RuntimeError(f"{method}: {msg['error']}")
|
||||
return msg.get("result")
|
||||
raise TimeoutError(f"{method}: no response in {timeout}s")
|
||||
|
||||
def call(self, tool, args=None):
|
||||
return self._rpc("tools/call", {"name": tool, "arguments": args or {}})
|
||||
|
||||
def ensure_connected(self, timeout=90):
|
||||
"""The server attaches to the app asynchronously after initialize; poll before UI calls."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
s = "".join(c.get("text", "") for c in (self.call("status") or {}).get("content", []))
|
||||
if '"connected":true' in s.replace(" ", ""):
|
||||
return s
|
||||
time.sleep(2)
|
||||
raise TimeoutError(f"app not connected after {timeout}s — is :desktopApp:hotRunAsync running? status: {s[:300]}")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
|
||||
|
||||
RAISE_SCRIPT = """
|
||||
tell application "System Events"
|
||||
repeat with p in (every process whose name is "java")
|
||||
repeat with w in (every window of p)
|
||||
if name of w is "Meshtastic Desktop" then
|
||||
set frontmost of p to true
|
||||
perform action "AXRaise" of w
|
||||
return "raised"
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
return "not found"
|
||||
"""
|
||||
|
||||
|
||||
def raise_app():
|
||||
r = subprocess.run(["osascript", "-e", RAISE_SCRIPT], capture_output=True, text=True, timeout=30)
|
||||
print((r.stdout or r.stderr).strip())
|
||||
|
||||
|
||||
def text_of(result):
|
||||
out = []
|
||||
for c in (result or {}).get("content", []):
|
||||
if c.get("type") == "text":
|
||||
out.append(c["text"])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def save_image(result, path):
|
||||
for c in (result or {}).get("content", []):
|
||||
if c.get("type") == "image":
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(c["data"]))
|
||||
return True
|
||||
# some tools return the base64 inline in text
|
||||
t = text_of(result)
|
||||
m = re.search(r"[A-Za-z0-9+/=]{200,}", t or "")
|
||||
if m:
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(m.group(0)))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
repo = os.getcwd()
|
||||
if argv and argv[0] == "--repo":
|
||||
repo = argv[1]
|
||||
argv = argv[2:]
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
return 2
|
||||
mcp = HotMcp(repo)
|
||||
UI_CMDS = {"windows", "tree", "click", "longclick", "type", "scroll_to", "ss", "reload", "restart", "reset_ui", "err", "logs"} # "raise" is local, no app connection needed
|
||||
try:
|
||||
for cmd in argv:
|
||||
name, _, val = cmd.partition("=")
|
||||
if name in UI_CMDS:
|
||||
mcp.ensure_connected()
|
||||
if name == "tools":
|
||||
r = mcp._rpc("tools/list", {})
|
||||
for t in r.get("tools", []):
|
||||
print(f"{t['name']}: {json.dumps(t.get('inputSchema', {}).get('properties', {}))}")
|
||||
elif name == "status":
|
||||
print(text_of(mcp.call("status")))
|
||||
elif name == "wait":
|
||||
mcp.ensure_connected(timeout=120)
|
||||
print("connected")
|
||||
elif name == "windows":
|
||||
print(text_of(mcp.call("list_windows")))
|
||||
elif name == "tree":
|
||||
t = text_of(mcp.call("get_semantic_tree"))
|
||||
if val:
|
||||
pat = re.compile(re.escape(val), re.I)
|
||||
print("\n".join(ln for ln in t.splitlines() if pat.search(ln)))
|
||||
else:
|
||||
print(t)
|
||||
elif name == "click":
|
||||
print(text_of(mcp.call("click", {"nodeId": int(val)})))
|
||||
elif name == "longclick":
|
||||
print(text_of(mcp.call("long_click", {"nodeId": int(val)})))
|
||||
elif name == "type":
|
||||
nid, _, text = val.partition(":")
|
||||
print(text_of(mcp.call("type_text", {"nodeId": int(nid), "text": text})))
|
||||
elif name == "scroll_to":
|
||||
nid, _, idx = val.partition(":")
|
||||
print(text_of(mcp.call("scroll_to_index", {"nodeId": int(nid), "index": int(idx or 0)})))
|
||||
elif name == "ss":
|
||||
r = mcp.call("take_screenshot", {"save_to": os.path.abspath(val)})
|
||||
print(text_of(r) or f"saved {val}")
|
||||
elif name in ("reload", "restart", "reset_ui"):
|
||||
print(text_of(mcp.call(name)))
|
||||
elif name == "raise":
|
||||
raise_app()
|
||||
time.sleep(1)
|
||||
elif name == "err":
|
||||
print(text_of(mcp.call("get_ui_error")))
|
||||
elif name == "logs":
|
||||
print(text_of(mcp.call("get_logs")))
|
||||
elif name == "sleep":
|
||||
time.sleep(float(val))
|
||||
else:
|
||||
print(f"unknown command: {cmd}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"--- {cmd} done ---", file=sys.stderr)
|
||||
finally:
|
||||
mcp.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive the Meshtastic Android app on an emulator/device over adb.
|
||||
|
||||
Scripted bring-up (never hand-walk onboarding): launches MainActivity with the
|
||||
debug-only skip_onboarding extra and a /connections deeplink that auto-connects
|
||||
to a TCP radio — pair it with a replay-sim radio (an AVD reaches the host at
|
||||
10.0.2.2). Handles the trust dialog newer builds pop on first connect.
|
||||
|
||||
Usage:
|
||||
driver_emulator.py [-s SERIAL] [-p PACKAGE] CMD [CMD ...]
|
||||
|
||||
Commands (executed left to right):
|
||||
connect[=ADDR] force-stop, then deeplink-launch and auto-connect.
|
||||
ADDR defaults to t10.0.2.2:4403 (t=TCP, x=BLE, s=serial,
|
||||
n=disconnect). Waits for and accepts the trust dialog.
|
||||
launch plain launch (skip_onboarding, no deeplink)
|
||||
stop force-stop the app
|
||||
dump print the uiautomator XML of the current screen
|
||||
find=TEXT print nodes whose text/desc contains TEXT (with bounds)
|
||||
tap_text=TEXT tap the center of the first clickable node matching TEXT
|
||||
tap=X,Y tap raw coordinates
|
||||
text=STRING type text into the focused field
|
||||
key=KEYCODE send a keycode (e.g. 4 = BACK — careful, closes dialogs)
|
||||
swipe=X1,Y1,X2,Y2 swipe (use x≈30 in lists; mid-screen swipes get eaten by maps)
|
||||
ss=PATH.png screenshot to a local file
|
||||
wait_text=TEXT poll up to 60 s until TEXT appears on screen
|
||||
sleep=SECONDS pause
|
||||
|
||||
Example — bring the app up against a replay sim on host port 4404:
|
||||
driver_emulator.py connect=t10.0.2.2:4404 wait_text=RPLY ss=/tmp/emu.png
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
SERIAL = None
|
||||
PKG = "com.geeksville.mesh.fdroid.debug"
|
||||
ACTIVITY = "org.meshtastic.app.MainActivity"
|
||||
|
||||
|
||||
def adb(*args):
|
||||
cmd = ["adb"] + (["-s", SERIAL] if SERIAL else []) + list(args)
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or b"").decode(errors="replace").strip()
|
||||
raise RuntimeError(f"adb {' '.join(args)} failed ({r.returncode}): {err[:300]}")
|
||||
return (r.stdout or b"").decode(errors="replace")
|
||||
|
||||
|
||||
def ui_dump():
|
||||
adb("shell", "uiautomator", "dump", "/sdcard/ui.xml")
|
||||
return adb("shell", "cat", "/sdcard/ui.xml")
|
||||
|
||||
|
||||
def nodes(xml):
|
||||
try:
|
||||
root = ET.fromstring(xml)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
out = []
|
||||
for n in root.iter("node"):
|
||||
out.append(n.attrib)
|
||||
return out
|
||||
|
||||
|
||||
def center(bounds):
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
if not m:
|
||||
return None
|
||||
x1, y1, x2, y2 = map(int, m.groups())
|
||||
return (x1 + x2) // 2, (y1 + y2) // 2
|
||||
|
||||
|
||||
def find(text, clickable_only=False, exact=False):
|
||||
for n in nodes(ui_dump()):
|
||||
t, d = n.get("text", ""), n.get("content-desc", "")
|
||||
if exact:
|
||||
hit = text.lower() in (t.lower(), d.lower())
|
||||
else:
|
||||
hit = text.lower() in (t + " " + d).lower()
|
||||
if hit and (not clickable_only or n.get("clickable") == "true"):
|
||||
yield n
|
||||
|
||||
|
||||
def tap_text(text):
|
||||
# exact text match first — substring matching taps "Stop Connecting" when you want "Connect"
|
||||
for n in find(text, exact=True):
|
||||
c = center(n.get("bounds", ""))
|
||||
if c:
|
||||
adb("shell", "input", "tap", str(c[0]), str(c[1]))
|
||||
return f"tapped exact {text!r} at {c}"
|
||||
for n in find(text, clickable_only=True):
|
||||
c = center(n.get("bounds", ""))
|
||||
if c:
|
||||
adb("shell", "input", "tap", str(c[0]), str(c[1]))
|
||||
return f"tapped {text!r} at {c}"
|
||||
# fall back to any match (some rows are labels inside a clickable parent)
|
||||
for n in find(text):
|
||||
c = center(n.get("bounds", ""))
|
||||
if c:
|
||||
adb("shell", "input", "tap", str(c[0]), str(c[1]))
|
||||
return f"tapped (non-clickable match) {text!r} at {c}"
|
||||
return f"NOT FOUND: {text!r}"
|
||||
|
||||
|
||||
def wait_text(text, timeout=60):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if any(True for _ in find(text)):
|
||||
return f"found {text!r}"
|
||||
time.sleep(3)
|
||||
return f"TIMEOUT waiting for {text!r}"
|
||||
|
||||
|
||||
def connect(addr):
|
||||
adb("shell", "am", "force-stop", PKG)
|
||||
time.sleep(1)
|
||||
adb(
|
||||
"shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}",
|
||||
"--ez", "skip_onboarding", "true",
|
||||
"-a", "android.intent.action.VIEW",
|
||||
"-d", f"https://meshtastic.org/connections?address={addr}",
|
||||
)
|
||||
# Builds >2.8.1 pop a trust dialog on first connect to a new device. Match its
|
||||
# title, not bare "Connect" — that substring also matches "Stop Connecting".
|
||||
r = wait_text("Connect to this device", timeout=30)
|
||||
if r.startswith("found"):
|
||||
print(tap_text("Connect"))
|
||||
else:
|
||||
print("no trust dialog seen — verifying the connection directly")
|
||||
# A missing dialog does not prove success (the launch or deeplink may have failed):
|
||||
# require the Connection screen's Disconnect button before claiming victory.
|
||||
v = wait_text("Disconnect", timeout=60)
|
||||
if not v.startswith("found"):
|
||||
return f"FAILED: launched with {addr}, but no connected state appeared ({v})"
|
||||
return f"connected via {addr}"
|
||||
|
||||
|
||||
def main():
|
||||
global SERIAL, PKG
|
||||
argv = sys.argv[1:]
|
||||
while argv and argv[0] in ("-s", "-p"):
|
||||
if len(argv) < 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
if argv[0] == "-s":
|
||||
SERIAL = argv[1]
|
||||
else:
|
||||
PKG = argv[1]
|
||||
argv = argv[2:]
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
return 2
|
||||
for cmd in argv:
|
||||
name, _, val = cmd.partition("=")
|
||||
if name == "connect":
|
||||
res = connect(val or "t10.0.2.2:4403")
|
||||
print(res)
|
||||
if res.startswith("FAILED"):
|
||||
return 1
|
||||
elif name == "launch":
|
||||
adb("shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}", "--ez", "skip_onboarding", "true")
|
||||
print("launched")
|
||||
elif name == "stop":
|
||||
adb("shell", "am", "force-stop", PKG)
|
||||
print("stopped")
|
||||
elif name == "dump":
|
||||
print(ui_dump())
|
||||
elif name == "find":
|
||||
for n in find(val):
|
||||
print(f"{n.get('text') or n.get('content-desc')!r} clickable={n.get('clickable')} bounds={n.get('bounds')}")
|
||||
elif name == "tap_text":
|
||||
res = tap_text(val)
|
||||
print(res)
|
||||
if res.startswith("NOT FOUND"):
|
||||
return 1
|
||||
elif name == "tap":
|
||||
x, y = val.split(",")
|
||||
adb("shell", "input", "tap", x, y)
|
||||
print(f"tapped {x},{y}")
|
||||
elif name == "text":
|
||||
adb("shell", "input", "text", val)
|
||||
print("typed (beware: 'input text' can append a trailing space)")
|
||||
elif name == "key":
|
||||
adb("shell", "input", "keyevent", val)
|
||||
print(f"key {val}")
|
||||
elif name == "swipe":
|
||||
adb("shell", "input", "swipe", *val.split(","))
|
||||
print(f"swipe {val}")
|
||||
elif name == "ss":
|
||||
with open(val, "wb") as f:
|
||||
subprocess.run(
|
||||
["adb"] + (["-s", SERIAL] if SERIAL else []) + ["exec-out", "screencap", "-p"],
|
||||
stdout=f, timeout=60, check=True,
|
||||
)
|
||||
print(f"saved {val}")
|
||||
elif name == "wait_text":
|
||||
res = wait_text(val)
|
||||
print(res)
|
||||
if res.startswith("TIMEOUT"):
|
||||
return 1
|
||||
elif name == "sleep":
|
||||
time.sleep(float(val))
|
||||
else:
|
||||
print(f"unknown command: {cmd}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"--- {cmd} done ---", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user