eff352b2ea
Run 10537 reached node boot but the simulator crashed again at Graph construction: lib/gui.py move_figure() unconditionally touches the Tk window manager (canvas.manager.window), which does not exist under Agg. Patch it to a no-op guard alongside the TkAgg->Agg backend swap. Also feed the simulator's interactive cmdloop from a FIFO held open until teardown — CI stdin is EOF, so the sim would otherwise exit immediately after booting the nodes.
225 lines
8.5 KiB
Bash
Executable File
225 lines
8.5 KiB
Bash
Executable File
#!/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 <dir containing binary '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"
|
|
SIM_ARGS=("$NODES")
|
|
if [ "$MODE" = native ]; then
|
|
[ -n "$PROGRAM" ] && [ -d "$PROGRAM" ] || { echo "--mode native needs --program <dir>" >&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")
|
|
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) =="
|
|
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 "$@"
|
|
)
|