From 38d4fde1608ea7a2b227a66781c28fbbf14b71c8 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:21:41 +0000 Subject: [PATCH] feat(agents): add the run-meshtastic-android skill with desktop and emulator drivers (#6955) Co-authored-by: Claude Fable 5 --- .../skills/run-meshtastic-android/SKILL.md | 208 ++++++++++++++ .../skills/run-meshtastic-android/driver.py | 269 ++++++++++++++++++ .../run-meshtastic-android/driver_emulator.py | 215 ++++++++++++++ 3 files changed, 692 insertions(+) create mode 100644 .claude/skills/run-meshtastic-android/SKILL.md create mode 100644 .claude/skills/run-meshtastic-android/driver.py create mode 100644 .claude/skills/run-meshtastic-android/driver_emulator.py diff --git a/.claude/skills/run-meshtastic-android/SKILL.md b/.claude/skills/run-meshtastic-android/SKILL.md new file mode 100644 index 0000000000..35c41a0c23 --- /dev/null +++ b/.claude/skills/run-meshtastic-android/SKILL.md @@ -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 `--- 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:`, an AVD at `10.0.2.2:`. 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=`, 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 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. diff --git a/.claude/skills/run-meshtastic-android/driver.py b/.claude/skills/run-meshtastic-android/driver.py new file mode 100644 index 0000000000..266cc875a0 --- /dev/null +++ b/.claude/skills/run-meshtastic-android/driver.py @@ -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()) diff --git a/.claude/skills/run-meshtastic-android/driver_emulator.py b/.claude/skills/run-meshtastic-android/driver_emulator.py new file mode 100644 index 0000000000..6802eae681 --- /dev/null +++ b/.claude/skills/run-meshtastic-android/driver_emulator.py @@ -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())