#!/usr/bin/env bash # Boot N Meshtasticator nodes and run the plugin's radio-level e2e tests # (plugin/tests/e2e) against node 0. # # Meshtasticator's interactive simulator (https://meshtastic.org/docs/software/meshtasticator/) # runs the real MeshtasticD device software per node and simulates the LoRa # PHY, so these tests exercise the adapter's real TCP/protobuf session, # inbound/outbound text flow across the simulated mesh, and chunked sends — # without physical radios. It cannot validate silicon-level RF. # # Prerequisites # * Linux host, OR Docker (macOS/Windows): the simulator auto-falls back to # Docker on non-Linux and can build MeshtasticD itself (`-d`). # * Native Linux mode: build MeshtasticD's 'native' target (PlatformIO, # select 'native') and pass --program . # * uv + python 3.13 for the contract venv (plugin/.venv-contract). # # Docker on a remote daemon: if DOCKER_HOST points at a tcp:// daemon, the # simulator's own control connections assume nodes listen on localhost, so # this script starts lightweight localhost->daemon TCP forwarders for the node # ports first (scripts/meshtasticator_e2e/tcp_forward.py). Nothing is # forwarded when the daemon is local (unset/unix DOCKER_HOST). # # Usage # MESHTASTICATOR_DIR=/path/to/meshtasticator scripts/meshtasticator_e2e/run_e2e.sh # # docker mode (default): ... run_e2e.sh --mode docker # # native mode: ... run_e2e.sh --mode native --program /path/to/firmware/.pio/build/native # # node count / channel: ... run_e2e.sh --nodes 3 --channel-index 0 # # Environment # MESHTASTICATOR_DIR path to a clone of github.com/meshtastic/meshtasticator # E2E_VENV python env with hermes-agent + plugin deps installed # (default: plugin/.venv-contract — see scripts/contract-test.sh, # then `uv pip install --python plugin/.venv-contract/bin/python -e plugin`) # SIM_VENV optional python env holding the simulator's deps # (meshtasticator/requirements.txt + `pip install docker`); # defaults to plain `python3`. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SIM_DIR="${MESHTASTICATOR_DIR:?set MESHTASTICATOR_DIR to a meshtasticator clone}" NODES=3 MODE=docker PROGRAM="" CHANNEL_INDEX=0 HOST=127.0.0.1 VENV="${E2E_VENV:-$REPO_ROOT/plugin/.venv-contract}" if [ -n "${SIM_VENV:-}" ]; then SIM_PYTHON="$SIM_VENV/bin/python" else SIM_PYTHON="${SIM_PYTHON:-python3}" fi while [ $# -gt 0 ]; do case "$1" in --nodes) NODES="$2"; shift 2 ;; --mode) MODE="$2"; shift 2 ;; --program) PROGRAM="$2"; shift 2 ;; --channel-index) CHANNEL_INDEX="$2"; shift 2 ;; --host) HOST="$2"; shift 2 ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac done command -v uv >/dev/null 2>&1 || { echo "uv required" >&2; exit 1; } [ -d "$SIM_DIR" ] || { echo "meshtasticator not found at $SIM_DIR" >&2; exit 1; } [ -x "$VENV/bin/python" ] || { echo "e2e venv missing at $VENV (see scripts/contract-test.sh)" >&2; exit 1; } export MPLBACKEND=Agg # headless matplotlib (no display needed) # --------------------------------------------------------------------------- # Remote-daemon port forwarding: Meshtasticator talks to nodes on localhost. # --------------------------------------------------------------------------- DOCKER_TARGET="" if [[ "${DOCKER_HOST:-}" == tcp://* ]]; then DOCKER_TARGET="${DOCKER_HOST#tcp://}" DOCKER_TARGET="${DOCKER_TARGET%%:*}" if [ "$DOCKER_TARGET" = "127.0.0.1" ] || [ "$DOCKER_TARGET" = "localhost" ]; then DOCKER_TARGET="" fi fi FWD_PIDS=() PORT_BASE=4404 # matches meshtasticator lib/interactive.py TCP_PORT_OFFSET start_forwards() { for ((i = 0; i < NODES; i++)); do port=$((PORT_BASE + i)) "$VENV/bin/python" "$REPO_ROOT/scripts/meshtasticator_e2e/tcp_forward.py" \ --listen "127.0.0.1:$port" --target "$DOCKER_TARGET:$port" >/dev/null 2>&1 & FWD_PIDS+=("$!") done sleep 1 } SIM_LOG="$REPO_ROOT/meshtasticator-sim.log" # Deterministic topology: random placement may leave nodes out of range # (nothing was ever delivered between nodes). Provide a --from-file scenario # with all nodes within ~10 m of each other so RF adjacency is guaranteed. NODE_CONF_DIR="$SIM_DIR/out" mkdir -p "$NODE_CONF_DIR" "$VENV/bin/python" - "$NODE_CONF_DIR/nodeConfig.yaml" "$NODES" <<'PY' import sys from pathlib import Path path, count = sys.argv[1], int(sys.argv[2]) conf = {} for i in range(count): x = float((i - (count - 1) / 2) * 10.0) # 10 m spacing around the origin conf[i] = { "x": x, "y": 0.0, "z": 1.0, "isRouter": False, "isRepeater": False, "isClientMute": False, "hopLimit": 3, "antennaGain": 0, "neighborInfo": False, } Path(path).write_text( "".join(f"{k}:\n" + "".join(f" {ck}: {cv}\n" for ck, cv in v.items()) for k, v in conf.items()) ) print(f"wrote deterministic node scenario ({count} nodes) to {path}") PY SIM_ARGS=("--from-file" -v) # node count/positions come from nodeConfig.yaml if [ "$MODE" = native ]; then [ -n "$PROGRAM" ] && [ -d "$PROGRAM" ] || { echo "--mode native needs --program " >&2; exit 2; } SIM_ARGS+=(-p "$PROGRAM") else SIM_ARGS+=(-d) fi if [ -n "$DOCKER_TARGET" ]; then echo "== remote docker daemon detected ($DOCKER_TARGET): starting localhost port forwards ==" start_forwards PROBE_HOST="$DOCKER_TARGET" else PROBE_HOST="$HOST" fi # The simulator assumes an interactive Tk desktop: lib/gui.py forces # matplotlib.use("TkAgg") at import and move_figure() touches the Tk window # manager at Graph construction — both crash headless runs even under # MPLBACKEND=Agg. Neutralize them in the pinned checkout (only the interactive # GUI modes, never used by this harness, are lost). echo "== patching meshtasticator lib/gui.py for headless runs ==" "$VENV/bin/python" - "$SIM_DIR" <<'PY' import sys from pathlib import Path gui = Path(sys.argv[1]) / "lib/gui.py" src = gui.read_text() src = src.replace('matplotlib.use("TkAgg")', 'matplotlib.use("Agg")') old = 'def move_figure(fig, x, y):\n fig.canvas.manager.window.wm_geometry("+%d+%d" % (x, y))' new = ('def move_figure(fig, x, y):\n' ' # Headless (Agg) backends have no Tk window manager.\n' ' try:\n' ' fig.canvas.manager.window.wm_geometry("+%d+%d" % (x, y))\n' ' except AttributeError:\n' ' pass') assert old in src, "move_figure source no longer matches the pinned meshtasticator" src = src.replace(old, new) gui.write_text(src) print("patched lib/gui.py") # --- RF instrumentation (prints land in meshtasticator-sim.log) --------------- # Why nothing is delivered between nodes is not yet known; log every packet the # sim's per-node interfaces observe so the next failing run explains itself. interactive = Path(sys.argv[1]) / "lib" / "interactive.py" src = interactive.read_text() old = ' def on_receive(self, interface, packet):\n' new = old + ( ' print(f"[sim] on_receive port={getattr(interface, \'portNumber\', None)} ' 'decoded_keys={list(packet.get(\'decoded\', {}).keys())}")\n' ) assert old in src, "on_receive signature moved in the pinned meshtasticator" src = src.replace(old, new, 1) old = ' pub.subscribe(self.on_receive, "meshtastic.receive.simulator")' new = old + ( '\n pub.subscribe(' 'lambda interface, packet: print(f"[sim-text] port={getattr(interface, \'portNumber\', None)} ' 'text={packet.get(\'decoded\', {}).get(\'text\')!r}"), "meshtastic.receive.text")' ) assert old in src, "simulator subscription moved in the pinned meshtasticator" src = src.replace(old, new, 1) old = ' rxs, rssis, snrs = self.calc_receivers(transmitter, receivers)' new = old + ( '\n print(f"[sim] TX node={transmitter.nodeid} candidates={[n.nodeid for n in receivers]} ' 'rxs={[n.nodeid for n in rxs]} rssis={rssis}")' ) assert old in src, "calc_receivers call moved in the pinned meshtasticator" src = src.replace(old, new, 1) interactive.write_text(src) print("patched lib/interactive.py (RF instrumentation)") PY if [ "$MODE" = docker ]; then # Warm the node image on the (possibly remote) daemon first: the simulator # starts node processes ~4s after the container is created and control # connections must succeed quickly, so the pull cannot sit in that path. echo "== warming meshtastic/meshtasticd image ==" docker pull meshtastic/meshtasticd fi echo "== booting Meshtasticator ($MODE) with $NODES node(s) from $SIM_DIR ==" # The simulator ends in an interactive cmdloop reading stdin; an EOF there # (e.g. CI) would make it exit right after booting the nodes. Feed it from a # FIFO whose write end this shell keeps open until cleanup. SIM_FIFO="$(mktemp -u /tmp/meshtasticator-XXXXXX.fifo)" mkfifo "$SIM_FIFO" ( cd "$SIM_DIR" "$SIM_PYTHON" interactiveSim.py "${SIM_ARGS[@]}" <"$SIM_FIFO" >"$SIM_LOG" 2>&1 ) & SIM_PID=$! exec 9>"$SIM_FIFO" # hold the write end open -> sim stdin never hits EOF rm -f "$SIM_FIFO" cleanup() { echo "== tearing down simulator (pid $SIM_PID) ==" # Graceful 'exit' through the cmdloop (avoids hundreds of EOF spam lines in # sim.log and lets the sim close its own nodes/container). echo exit >&9 2>/dev/null || true sleep 2 exec 9>&- 2>/dev/null || true # release the FIFO write end (sim stdin EOF) kill "$SIM_PID" 2>/dev/null || true wait "$SIM_PID" 2>/dev/null || true for pid in "${FWD_PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true done if [ "$MODE" = docker ]; then # The sim names its container/volume (auto_remove only fires on stop). docker rm -f Meshtastic >/dev/null 2>&1 || true docker volume rm Meshtasticator >/dev/null 2>&1 || true fi } trap cleanup EXIT echo "== waiting for $NODES node TCP API port(s) to accept connections ==" READY="" for _ in $(seq 1 180); do # up to ~5 min: docker pulls meshtastic/meshtasticd on first run # Probe the upstream node host (the docker daemon when forwards are active, # localhost otherwise) — probing the forwarders themselves would always pass. READY="$("$VENV/bin/python" - "$PROBE_HOST" "$NODES" <<'PY' || true import socket, sys host, count = sys.argv[1], int(sys.argv[2]) found = [] # Meshtasticator assigns node n -> TCP port starting at 4404 (see # lib/interactive.py TCP_PORT_OFFSET); probe a small window to be safe. for port in range(4403, 4403 + 16): try: with socket.create_connection((host, port), timeout=0.5): found.append(port) except OSError: pass if len(found) >= count: break print(",".join(map(str, found[:count]))) PY )" if [ -n "$READY" ]; then break fi sleep 2 done if [ -z "$READY" ]; then echo "error: no Meshtasticator node ports became reachable on $PROBE_HOST (see $SIM_LOG)" >&2 exit 1 fi echo " node ports: $READY (probed on $PROBE_HOST)" # Ports accept before the node firmware finished booting inside the container; # give meshtasticd a moment to open its TCP API for real. echo "== settling (node firmware boot) ==" sleep 15 echo "== running radio-level e2e tests ==" ( cd "$REPO_ROOT" MESHTASTICATOR_E2E=1 \ E2E_HOST="$HOST" \ E2E_PORTS="$READY" \ E2E_CHANNEL_INDEX="$CHANNEL_INDEX" \ "$VENV/bin/python" -m pytest plugin/tests/e2e -q "$@" ) RC=$? if [ $RC -ne 0 ] && [ "$MODE" = docker ]; then # Capture per-node meshtasticd logs before cleanup removes the container. echo "== dumping node logs (pytest rc=$RC) ==" docker exec Meshtastic sh -c 'for f in /home/out_*.log; do echo "----- $f -----"; tail -n 60 "$f" 2>/dev/null; done' \ >"$REPO_ROOT/meshtasticator-nodes.log" 2>&1 || true echo " node logs: $REPO_ROOT/meshtasticator-nodes.log" fi exit $RC