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.
76
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
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.
: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.:androidApp fdroid debug): drive through
.claude/skills/run-meshtastic-android/driver_emulator.py — scripted deeplink
bring-up, uiautomator-based taps, screencap.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).~/.claude/bin/gradle-queue.
Everything after its -- is Gradle arguments — it runs ./gradlew itself
(gradle-queue -- ./gradlew tasks fails with Task './gradlew' not found).~/.gradle/jdks/jetbrains_s_r_o_-25-*/…/Contents/Home (the drivers find it themselves).adb devices) with the fdroid debug build installed
(./gradlew :androidApp:installFdroidDebug via the queue if missing).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.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 launchingLaunch (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:hotRunAsyncBUILD 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 editsRun 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.
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.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.pngconnect 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).
./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.
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.
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.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).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.adb shell sleep 2 or the emulator drops offline.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.raise before ss (window wasn't frontmost).connected:false forever from status → stray MainKt from another checkout
or worktree; pgrep -fl MainKt, kill, relaunch.tap_text=Connect (exact match wins now).connected:true → not stuck; config
download in progress. Check the Nodes tab for the sim's nodes.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).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.d003a21
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.