| 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:
pgrep -fl "MainKt|devtools.Main"
Launch (the Nix dev shell's Darwin stdenv breaks the MapLibre FFI — strip it):
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:
python3 .claude/skills/run-meshtastic-android/driver.py tree
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
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:
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:
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:
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
Connecting" and "Reconnecting…". 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.