| name | meshtastic-e2e |
| license | GPL-3.0-only |
| description | Closed-loop end-to-end testing of a Meshtastic device and a Meshtastic app (Android, Apple, or the Compose desktop app) together. Use when validating that an action on the radio (send a text, change config, a node beacon, a power-cycle) surfaces correctly in the app UI, or that an action in the app produces the correct on-air/on-device result. Drives the firmware device via the Meshtastic MCP server and the app via the Android CLI (`android` + `adb`), the Apple toolchain (`xcrun simctl` + `idb`), or the Compose Hot Reload MCP server for desktop, then cross-asserts one plane against the other. |
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 — 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-able PASS …/FAIL … lines.
references/loop-inbound.md — device → app message render
references/loop-outbound.md — app → device wire truth
references/loop-node-sync.md — node appears in app node list
references/loop-config-writeback.md — app setting persists on device (RAM + NVS)
references/loop-resilience.md — uhubctl power-cycle fault injection
references/emulator-lab.md — hardware-free Android: AVD app over TCP + native UDP mesh
references/replay-app-features.md — drive app behavior via the replay engine: feature injection (geofence/waypoints/alerts via replay_inject), sustained-load/stress (conference-stress preset, duration/rate pacing, self-verifying achieved_rate), mDNS/Bonjour auto-discovery, traceroute-log population, and tapback/bot-plane reactions; logcat/notification oracles + gotchas
references/simulator-apple.md — hardware-free Apple: iOS Simulator / macOS app over TCP (127.0.0.1) + native UDP mesh
references/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 in references/journeys/.
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 the triage_e2e_failure MCP 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_onboarding is MainActivity's debug-only EXTRA_SKIP_ONBOARDING — bypasses the
whole intro flow on a fresh install (release builds ignore it).
/connections?address=t<host>:<port> auto-connects (transport prefixes: t TCP, x BLE
MAC, s serial). address=n disconnects. Full path list:
docs/en/developer/navigation-and-deep-links.md in 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_stop also 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.py targets the iOS Simulator only
(xcrun simctl, idb). Physical iPhone/iPad requires code signing + provisioning profiles,
libimobiledevice/usbmuxd for 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
status until connected: true before any other call — the server accepts
requests before the app has connected to it.
get_semantic_tree is the oracle: roles, text, selected/focused, actions and
bounds — everything the journey-driven approach in references/journeys.md needs.
click / type_text / scroll address nodes by nodeId from that tree.
take_screenshot exists but its output depends on the host renderer; prefer the tree.
reload applies source edits into the running app (await_reload when 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
TESTER=/dev/cu.usbmodem101
adb devices
adb shell pm list packages | grep meshtastic
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 N then 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 layout snapshots. Start it before the
stimulus, query the window after.
- One MCP call per serial port at a time (exclusive lock): open → act → close.
layout can fail on WebView/animation — fall back to screen capture --annotate
Loop 1 — inbound message (device → app)
Stimulate from the tester radio; assert the bubble renders in the app.
TOKEN="E2E-$(date +%s)"
$MCP -m meshtastic_mcp.cli ...
$MCP -u -c "
import meshtastic.serial_interface as si
i=si.SerialInterface('$TESTER'); i.sendText('$TOKEN', wantAck=False); i.close()"
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:
- App stimulus — focus the compose field, type the token, tap send:
android layout --pretty | jq '.[] | select(.interactions|index("focusable"))'
adb shell input tap <cx> <cy>
adb shell input text "$TOKEN"
adb shell input tap <send_cx> <send_cy>
- Device oracle — the tester must receive it; query the recorder window:
$MCP -c "from meshtastic_mcp import log_query as q,json;
print(json.dumps(q.packets_window(max=20)))" | grep -i "TEXT_MESSAGE_APP"
Decode the payload hex / matched text for $TOKEN. PASS when a TEXT_MESSAGE_APP
packet 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 78 ATAK_PLUGIN_V2; v1 rides 72). replay/tak_server.py
capture_to_cot_events() reproduces the bridge's wire→TAKPacketV2→CoT path;
tests/unit/test_tak_bridge.py asserts the CoT is well-formed, typed
(a-f-G-U-C PLI), 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.py stands up CotTakServer from that squad, points
ATAK-CIV at 10.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). Emits LOOP 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
repo mcp-server/tests/ui/ and the /test slash command.
- App-only UI tests:
android journeys (references/journeys.md in the android-cli skill).
- This skill is the cross-plane layer that neither of those covers.