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 (
:androidAppfdroid 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 at127.0.0.1:<port>, an AVD at10.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./gradlewitself (gradle-queue -- ./gradlew tasksfails withTask './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:installFdroidDebugvia 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" # kill any hits before launching
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 # 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:
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:
hotRunAsyncdoes not accept--args(its option list: --auto, --className, --funName, --mainClass, --stdout/--stderr only) — deeplink launch means the plainruntask, which trades away hot reload. Long driving session →hotRunAsync+ the driver's click path; quick "get me a connected app" →run --args=….runblocks, 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 barepkillon 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 forBUILD SUCCESSFULin the text, not$?.tap_textmatches substrings: bareConnectalso 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
treefired 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_screenshotneeds the window visible —raisefirst (System EventsAXRaisetargeting 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_starton 4404. adb shell input textcan 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 2or 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./gradlewaftergradle-queue --; drop it.BUILD FAILED in 1sfromhotRunAsyncwith slots free → read the full output; the queue wrapper's exit code vanishes behind pipes.- Screenshot is your terminal →
raisebeforess(window wasn't frontmost). connected:falseforever fromstatus→ strayMainKtfrom 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.txtcarries 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
statusright after spawn can reportconnected:falsewhile the app is fine — the server attach is asynchronous;wait(or any UI command, which auto-waits) is the truth.