Meshtastic Device ↔ App E2E Testing
Two independent planes, each with its own stimulus and oracle. A closed-loop test stimulates one plane and asserts on the other.
| Plane | Stimulus | Oracle (observation) |
|---|---|---|
| Device (Meshtastic MCP) | send_text, set_config, push_fake_nodedb, send_input_event, uhubctl_cycle, traceroute |
recorder packets_window / telemetry_timeline / logs_window / events_window, device_info, list_nodes, serial logs, capture_screen+OCR |
| App — Android (android CLI + adb) | adb shell input tap/text/swipe, android run |
android layout --diff, android screen capture [--annotate] |
| App — Android, agent-driven (Artemis MCP) | mobile_run_task runs a whole journey out of context |
none — never its own oracle; the device plane decides (see references/journeys.md) |
| App — Apple (xcrun simctl + idb) | idb ui tap/text, simctl install/launch |
idb ui describe-all, simctl io screenshot |
| App — Desktop (Compose Hot Reload MCP) | click / type_text / scroll (by nodeId) |
get_semantic_tree, get_logs, get_ui_error |
Reference files (load the one you need)
references/topology.md— two-radio wiring, app-over-TCP, the uhubctl USB switch, preflight.references/harness.md— the hard rules (marker tokens, bounded polling, recorder oracle, adb nav primitives, verdict format). Read this before any loop.references/mesh_e2e.py— runnable device-plane helper (devices/info/send/recv-text/watch-tx/traceroute/recorder); emits grep-ablePASS …/FAIL …lines.references/loop-inbound.md— device → app message renderreferences/loop-outbound.md— app → device wire truthreferences/loop-node-sync.md— node appears in app node listreferences/loop-config-writeback.md— app setting persists on device (RAM + NVS)references/loop-resilience.md— uhubctl power-cycle fault injectionreferences/emulator-lab.md— hardware-free Android: AVD app over TCP + native UDP meshreferences/replay-app-features.md— drive app behavior via the replay engine: feature injection (geofence/waypoints/alerts viareplay_inject), sustained-load/stress (conference-stresspreset,duration/ratepacing, self-verifyingachieved_rate), mDNS/Bonjour auto-discovery, traceroute-log population, and tapback/bot-plane reactions; logcat/notification oracles + gotchasreferences/simulator-apple.md— hardware-free Apple: iOS Simulator / macOS app over TCP (127.0.0.1) + native UDP meshreferences/journeys.md— journey-driven UI (recommended over hardcoded coordinates): drive the app from a goal via the live a11y tree, version-resilient. Ships journey XML inreferences/journeys/. For Android, hand the journey to Artemis (mobile_run_task) rather than looping inline — same XML, runs out of context. Its verification is fail-open, so the recorder stays the verdict.references/vision-oracle.md— use a screenshot + vision as the assertion when the a11y tree is empty (WebView/Canvas/animation).references/triage.md— dual-plane root-cause analysis of a FAIL (pairs with thetriage_e2e_failureMCP prompt).
The device plane (mesh_e2e.py, native nodes, recorder) is platform-neutral and shared; only
the app plane differs (Android CLI+adb, Apple xcrun simctl+idb, or the Compose Hot Reload
MCP server for desktop).
Fast app bring-up — use this before any manual UI driving
Never hand-walk onboarding and the manual-IP dialog on a debug build; the app has a scripted path (and hand-walking it is how sessions waste ten minutes of taps):
adb shell am start -n <pkg>/org.meshtastic.app.MainActivity \
--ez skip_onboarding true \
-a android.intent.action.VIEW -d "https://meshtastic.org/connections?address=t10.0.2.2:4403"
skip_onboardingis MainActivity's debug-onlyEXTRA_SKIP_ONBOARDING— bypasses the whole intro flow on a fresh install (release builds ignore it)./connections?address=t<host>:<port>auto-connects (transport prefixes:tTCP,xBLE MAC,sserial).address=ndisconnects. Full path list:docs/en/developer/navigation-and-deep-links.mdin the app repo.- Builds after 2.8.1 pop a trust dialog ("Connect to this device?") on link-initiated
connects — one exact-match tap on Connect.
avd.connect_app_to_tcp()does all of this (bypass, deep link, dialog, fallback taps) — prefer it over raw adb. - Driving Settings/UI with a sim attached? Start it with
sim_profile={"traceroute_pairs_per_hour": 0}— the default sim injects traceroute responses that pop a modal over whatever you are testing (replay_stopalso works once the node DB is populated; the app keeps its nodes).
Topology (read first)
Three supported topologies — pick the one that matches your hardware:
| Topology | Device plane | App plane | DUT address |
|---|---|---|---|
| Emulator lab (no hardware) | meshtasticd native nodes via UDP multicast |
Android AVD | 10.0.2.2:<port> |
| Physical Android | Real radios via USB serial | USB-attached Android phone | adb reverse → 127.0.0.1:<port> |
| Physical Apple | Real radios via USB serial | iOS Simulator only (see caveat) | 127.0.0.1:<port> |
| Desktop app | Real radios via USB serial, or native nodes over UDP | Compose :desktopApp on the host |
127.0.0.1:<port> |
For physical Android: avd.tcp_dut_address(port, serial=<phone_serial>) sets up the
adb reverse tunnel automatically and returns the correct address. Pass it to
connect_app_to_tcp(). UI observation falls back from android layout to
adb exec-out uiautomator dump transparently — all helpers (poll_for_text, find_text,
_tap_text) work on both emulators and physical phones.
iOS physical devices — not supported.
apple_sim.pytargets the iOS Simulator only (xcrun simctl,idb). Physical iPhone/iPad requires code signing + provisioning profiles,libimobiledevice/usbmuxdfor device comms, and XCTest/XCUITest for UI automation (idb is simulator-only). This is a separate, significant project — not a configuration change.
Full wiring detail and the single-radio app-over-TCP workaround in references/topology.md.
One radio cannot be both tester and DUT (the serial port lock is exclusive).
Desktop app plane (Compose Hot Reload MCP)
CMP 1.12+ is what bundles the MCP-capable Compose Hot Reload, and Meshtastic-Android is
on 1.12.0 — so this plane is available there today. Its tracked .mcp.json registers the server as
compose-hot-reload (:desktopApp:hotMcpServer), so a session opened in that repo drives
the live desktop app with no rebuild between assertions. Start it with
./gradlew :desktopApp:hotRun, then:
- Poll
statusuntilconnected: truebefore any other call — the server accepts requests before the app has connected to it. get_semantic_treeis the oracle: roles, text,selected/focused, actions and bounds — everything the journey-driven approach inreferences/journeys.mdneeds.click/type_text/scrolladdress nodes bynodeIdfrom that tree.take_screenshotexists but its output depends on the host renderer; prefer the tree.reloadapplies source edits into the running app (await_reloadwhen started with--auto), so a fix can be re-asserted without restarting the mesh setup.
The device plane is unchanged: same radios, same recorder, same mesh_e2e.py.
Prerequisites
Before running any loop, verify all of these. Missing items cause silent failures, not clear errors.
| Requirement | Check | How to fix |
|---|---|---|
MESHTASTIC_FIRMWARE_ROOT set |
echo $MESHTASTIC_FIRMWARE_ROOT |
Set to your firmware checkout path |
| Two radios (TESTER + DUT) | list_devices() → ≥2 ports with likely_meshtastic=true |
Plug in both radios; use TCP DUT if only one physical radio |
| App reachable on the plane you are testing | Android: adb shell pm list packages | grep meshtastic · Apple: xcrun simctl listapps · Desktop: MCP status → connected: true |
android run / adb install; simctl install; ./gradlew :desktopApp:hotRun |
| Recorder running (process-global) | recorder_status() → running=true |
Auto-starts on first MCP serial call; captures every interface |
uhubctl available (Loop 5 only) |
uhubctl -l returns hub info |
brew install uhubctl; set MESHTASTIC_UHUBCTL_LOCATION_TESTER |
doctor() returns ok=true |
Call doctor() |
Run each fix_commands entry in order |
Bootstrap
export MESHTASTIC_FIRMWARE_ROOT="$HOME/meshtastic/firmware"
MCP="$MESHTASTIC_FIRMWARE_ROOT/mcp-server/.venv/bin/python"
S="$HOME/.agents/skills/meshtastic-e2e/references/mesh_e2e.py"
$MCP "$S" devices # list tester radios
TESTER=/dev/cu.usbmodem101 # pick one
adb devices # confirm the phone (DUT) is attached
adb shell pm list packages | grep meshtastic # confirm the app is installed
The MCP server (registered as user-scope meshtastic) exposes the 53 tools directly when
running inside Claude Code; the $MCP "$S" … helper is the standalone/CI path.
Hard rules (mesh is async + lossy — respect these or get flaky tests)
- Marker token per message. Never assert on a bare "hello". Embed a unique token
(
E2E-$(date +%s)-$RANDOM) so a busy 80-node mesh can't false-positive your grep. - Bounded polling, never
sleep Nthen assert once. Poll every 1 s up to a deadline. Use these ceilings: single-hop broadcast → 20 s; directed/PKI send → 30 s; multi-hop (≥2) → 45 s. Mesh delivery is best-effort; these cover the 99th-percentile lab case. - Warm up directed/PKI sends. Directed + encrypted sends need bilateral NodeInfo
(both sides hold each other's current pubkey). Broadcast (
^all) first to exchange, or send to a node already in both DBs. - The recorder is the device-side source of truth. It timestamps every RX packet to
JSONL; align those timestamps with
android layoutsnapshots. Start it before the stimulus, query the window after. - One MCP call per serial port at a time (exclusive lock): open → act → close.
layoutcan fail on WebView/animation — fall back toscreen capture --annotate- visual/OCR inspection.
Loop 1 — inbound message (device → app)
Stimulate from the tester radio; assert the bubble renders in the app.
TOKEN="E2E-$(date +%s)"
# 1. device stimulus: tester radio broadcasts (or directs to the DUT node)
$MCP -m meshtastic_mcp.cli ... # or drive send_text via the meshtastic API:
$MCP -u -c "
import meshtastic.serial_interface as si
i=si.SerialInterface('$TESTER'); i.sendText('$TOKEN', wantAck=False); i.close()"
# 2. app oracle: poll the UI tree for the token (bounded)
for t in $(seq 1 30); do
android layout 2>/dev/null | grep -q "$TOKEN" && { echo "PASS: rendered"; break; }
sleep 1
done
Open the messages screen in the app first (adb shell input to navigate, or android run
to the messages activity). Use layout --diff to keep only the changed bubble in context.
Loop 2 — outbound message (app → device)
Type+send in the app; assert the wire truth on the tester radio's recorder.
- Start the recorder, then drive the app:
# ensure recorder is capturing (it auto-starts when the MCP server is live; # standalone: open a SerialInterface to TESTER and call get_recorder().start()) - App stimulus — focus the compose field, type the token, tap send:
android layout --pretty | jq '.[] | select(.interactions|index("focusable"))' # find input adb shell input tap <cx> <cy> # focus compose field (must show "focused") adb shell input text "$TOKEN" adb shell input tap <send_cx> <send_cy> - Device oracle — the tester must receive it; query the recorder window:
Decode the payload hex / matched text for$MCP -c "from meshtastic_mcp import log_query as q,json; print(json.dumps(q.packets_window(max=20)))" | grep -i "TEXT_MESSAGE_APP"$TOKEN. PASS when aTEXT_MESSAGE_APPpacket from the DUT node carrying the token lands within the deadline.
Loop 3 — node sync (device → app)
push_fake_nodedb (or a real beacon) on the DUT radio → assert the node appears in the
app's node list (android layout over the nodes screen, match long_name/short_name).
Backstop the device truth with list_nodes.
Loop 4 — config write-back (app → device)
Change a setting in the app (e.g. region, device role, a channel name) → assert it persisted on the DUT radio:
$MCP -c "from meshtastic_mcp import admin,json; print(json.dumps(admin.get_config('lora', port='$DUT')))"
Reboot the radio (reboot MCP tool) and re-read to prove NVS persistence, not just RAM.
Loop 5 — resilience / fault injection
Mid-conversation, power-cycle a relay or the peer with uhubctl_cycle (needs uhubctl):
assert the app shows the node go offline → online and that a queued message recovers
once the path heals. This is the app-facing mirror of the firmware suite's
test_peer_offline_recovery.
Loop 6 — ATAK/iTAK render (sim TAK squad → TAK client)
Validates the TAK plane. Meshtastic apps (≥2.8) bridge mesh TAK traffic to a
connected ATAK/iTAK client via an in-app local TAK server that emits CoT
(the deprecated IMeshService plugin is gone). Two layers:
- Bridge-semantics (no emulator, in CI): the sim emits a TAKPacketV2 squad
(
replay_start(source=..., sim_profile={"tak": {"team_nodes": N, "wire": "v2"}})— v2 rides portnum 78ATAK_PLUGIN_V2; v1 rides 72).replay/tak_server.pycapture_to_cot_events()reproduces the bridge's wire→TAKPacketV2→CoT path;tests/unit/test_tak_bridge.pyasserts the CoT is well-formed, typed (a-f-G-U-CPLI), and carries the right callsign/position/GeoChat. Needs the[tak]extra (meshtastic-tak SDK). - App-plane (opt-in, needs an emulator + ATAK-CIV), bidirectional:
scripts/ci_atak_app_loop.pystands upCotTakServerfrom that squad, points ATAK-CIV at10.0.2.2:<port>(a pushed streaming-input.pref), launches it, and asserts (receive) a squad callsign marker renders and (send) ATAK's own self-PLI streams back, is captured, and converts to a mesh TAKPacketV2. ATAK-CIV is free (com.atakmap.app.civ, Play Store / tak.gov / GitHub; needs GLES 3.0). EmitsLOOP atak-render …+LOOP atak-send …. iTAK is iOS-only and App-Store-distributed, so in-simulator automation isn't practical — physical device only.
Both directions are unit-tested without hardware: test_tak_bridge.py covers
receive (mesh→CoT) and send (cot_to_wire, CoT→mesh); test_tak_server.py
asserts the server streams to a client and captures a client-authored CoT.
You can also point a real ATAK/WinTAK at CotTakServer directly (host:port,
plain TCP streaming input) to eyeball the sim's squad on a live map.
Reporting
Emit a compact verdict per loop: LOOP <n> <PASS|FAIL> token=<...> latency=<ms> hops=<n>.
On FAIL, attach: the app layout/screen capture at deadline, and the recorder
packets_window + logs_window tail for the same wall-clock window (they share epoch
timestamps — that alignment is the whole point of the dual-plane design).
Related
- Firmware-only device UI testing (OLED via
send_input_event+ camera/OCR): firmware repomcp-server/tests/ui/and the/testslash command. - App-only UI tests:
androidjourneys (references/journeys.mdin theandroid-cliskill). - This skill is the cross-plane layer that neither of those covers.