Run Meshtastic (Desktop & Emulator)

SkillDev tools

Lets your agent launch the Meshtastic app on desktop or an Android emulator, drive its UI, and take screenshots.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Run Meshtastic (Desktop & Emulator) skill

About this capability

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

What this skill tells your AI

The instructions your AI receives, as published by meshtastic/meshtastic-android in .claude/skills/run-meshtastic-android/SKILL.md and read by ahel’s review.

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"   # 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:

  • 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 visibleraise 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.

Signals

GitHub stars
2k
Forks
512
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
run-meshtastic-android
Source
github.com/meshtastic/meshtastic-android