diff --git a/.gitea/workflows/meshtasticator-e2e.yaml b/.gitea/workflows/meshtasticator-e2e.yaml new file mode 100644 index 0000000..6f97825 --- /dev/null +++ b/.gitea/workflows/meshtasticator-e2e.yaml @@ -0,0 +1,99 @@ +name: meshtasticator-e2e + +# Radio-level end-to-end validation against Meshtasticator's interactive +# simulator (real MeshtasticD per node + simulated LoRa PHY). Runs on demand +# until it is validated green on the runner; afterwards switch `on` to also +# trigger on pull_request touching plugin/ and this workflow. +# +# Docker access follows the deployment examples in +# https://git.ericxliu.me/eric/actions-docker (docker is available to jobs); +# Meshtasticator's docker mode publishes node TCP API ports (4404+n) from the +# meshtastic/meshtasticd container and the simulator connects to them on +# localhost. scripts/meshtasticator_e2e/run_e2e.sh starts localhost->daemon +# TCP forwards automatically when DOCKER_HOST is a tcp:// daemon, which is how +# this job is configured. + +on: + workflow_dispatch: + # pull_request: + # paths: + # - 'plugin/**' + # - 'scripts/meshtasticator_e2e/**' + # - '.gitea/workflows/meshtasticator-e2e.yaml' + +concurrency: + # One e2e run at a time: Meshtasticator hardcodes the container/volume name + # ("Meshtastic"/"Meshtasticator") on the shared docker daemon. + group: meshtasticator-e2e + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + DOCKER_HOST: tcp://docker.local:2375 + MESHTASTICATOR_COMMIT: 17ceb8231079d87b070abc6132181e4c6b20202d + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.13 (matches Hermes image) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + run: pip install uv + + # --- Hermes gateway contract env (mirrors .gitea/workflows/release.yaml) --- + - name: Clone pinned Hermes gateway source (deployed image tag) + run: | + git clone --depth 1 --branch v2026.8.31 \ + https://github.com/NousResearch/hermes-agent.git plugin/.hermes-src + test "$(git -C plugin/.hermes-src rev-parse HEAD)" = \ + "29112bef099274229cadff79cdff7bf7b99c4b77" + + - name: Editable install hermes-agent + pytest + plugin (meshtastic client lib) + run: | + uv venv --python 3.13 plugin/.venv-contract + uv pip install --python plugin/.venv-contract/bin/python -e plugin/.hermes-src + uv pip install --python plugin/.venv-contract/bin/python pytest + uv pip install --python plugin/.venv-contract/bin/python -e plugin + + # --- Simulator env (isolated: meshtasticator pins meshtastic~=2.6.1) --- + - name: Clone Meshtasticator at pinned commit + run: | + git clone https://github.com/meshtastic/meshtasticator.git "$HOME/meshtasticator" + git -C "$HOME/meshtasticator" checkout "$MESHTASTICATOR_COMMIT" + test "$(git -C "$HOME/meshtasticator" rev-parse HEAD)" = "$MESHTASTICATOR_COMMIT" + + - name: Simulator venv (its own requirements + docker SDK) + run: | + uv venv --python 3.13 "$HOME/sim-venv" + # NOTE: meshtasticator/requirements.txt pins meshtastic~=2.6.1, which + # predates the meshtastic/meshtasticd image it talks to and never + # observed TX packets (both e2e directions timed out). Install the + # simulator's deps but let meshtastic float to the current client — + # the version the official firmware_harness uses against the same + # meshtasticd simulator mode. + uv pip install --python "$HOME/sim-venv/bin/python" \ + meshtastic numpy matplotlib pandas \ + 'PyPubSub==4.0.3' simpy PyYAML protobuf docker + + - name: Run Meshtasticator e2e suite + run: | + export MESHTASTICATOR_DIR="$HOME/meshtasticator" + export SIM_VENV="$HOME/sim-venv" + export E2E_VENV="plugin/.venv-contract" + bash scripts/meshtasticator_e2e/run_e2e.sh --mode docker + + - name: Print simulator log (on failure) + if: failure() + run: | + echo "===== meshtasticator-sim.log (tail) =====" + tail -n 400 meshtasticator-sim.log 2>/dev/null || true + echo "===== meshtasticator-nodes.log (if any) =====" + tail -n 200 meshtasticator-nodes.log 2>/dev/null || true + echo "===== docker ps =====" + docker ps -a 2>/dev/null || true diff --git a/plugin/tests/e2e/test_meshtasticator_e2e.py b/plugin/tests/e2e/test_meshtasticator_e2e.py new file mode 100644 index 0000000..5d19278 --- /dev/null +++ b/plugin/tests/e2e/test_meshtasticator_e2e.py @@ -0,0 +1,296 @@ +"""End-to-end tests: hermes-meshtastic adapter against live Meshtasticator nodes. + +What these tests validate (the "radio-level" layer no contract test can): + * the adapter's real TCP/protobuf handshake against MeshtasticD (the actual + device software, not a mock) as run by the Meshtasticator interactive + simulator (https://meshtastic.org/docs/software/meshtasticator/), + * inbound text reception across the simulated LoRa mesh, + * outbound chunked sends arriving at another simulated node, + * the whole path: pubsub callback -> MessageEvent construction (real gateway + types) -> handle_message dispatch. + +What they do NOT validate (out of scope even for Meshtasticator): true RF +(silicon-level LoRa, real propagation/interference, regulatory duty cycle). + +Running +------- + 1. On a Linux host (or a machine with Docker): clone meshtastic/meshtasticator + and boot the interactive simulator (see scripts/meshtasticator_e2e/). + 2. Point the plugin's own contract venv at the booted nodes and run: + cd plugin && MESHTASTICATOR_E2E=1 E2E_PORTS=4404,4405 \ + E2E_CHANNEL_INDEX=0 .venv-contract/bin/python -m pytest tests/e2e -q + +Environment +----------- + MESHTASTICATOR_E2E = "1" enables these tests (required; without it the + module skips so ordinary unit/CI runs stay fast). + E2E_PORTS = comma-separated TCP API ports of the simulated nodes + (first = the node the adapter connects to, default 4404). + E2E_HOST = host where the simulator exposes the ports (default 127.0.0.1). + E2E_CHANNEL_INDEX = Meshtastic channel index the adapter listens/sends on + (default 0 = primary channel, matching a stock sim node). + +NOTE: this module is an initial scaffold. Structural validation (imports, +fixtures, flow) passes, but a full green run requires a Linux/Docker host with +a MeshtasticD build — tracked as the remaining verification step. +""" + +import asyncio +import os + +import pytest + +if os.environ.get("MESHTASTICATOR_E2E") != "1": + pytest.skip( + "set MESHTASTICATOR_E2E=1 to run against Meshtasticator nodes " + "(see scripts/meshtasticator_e2e/README.md)", + allow_module_level=True, + ) + +# Real Hermes gateway + real Meshtastic client library are required. +pytest.importorskip("gateway.platforms.base") +pytest.importorskip("meshtastic.tcp_interface") + +from gateway.config import PlatformConfig # noqa: E402 +from gateway.platform_registry import ( # noqa: E402 + PlatformEntry, + platform_registry, +) + +import hermes_meshtastic # noqa: E402 +from hermes_meshtastic import adapter as plugin_adapter # noqa: E402 + +E2E_HOST = os.environ.get("E2E_HOST", "127.0.0.1") +E2E_PORTS = [int(p) for p in os.environ.get("E2E_PORTS", "4404,4405").split(",") if p] +E2E_CHANNEL_INDEX = int(os.environ.get("E2E_CHANNEL_INDEX", "0")) +assert len(E2E_PORTS) >= 2, "E2E_PORTS needs at least two node ports (adapter + peer)" + +NODE0_PORT = E2E_PORTS[0] # the node our adapter connects to +NODE1_PORT = E2E_PORTS[1] # the peer node that sends / observes +CONNECT_TIMEOUT_S = 30 +RADIO_SETTLE_S = 2 + + +def _register_meshtastic_platform(): + """Register the plugin (mirrors discovery) so Platform('meshtastic') resolves.""" + if platform_registry.is_registered("meshtastic"): + return + kwargs = {} + + class _Ctx: + def register_platform(self, **kw): + kwargs.update(kw) + + hermes_meshtastic.register(_Ctx()) + platform_registry.register( + PlatformEntry( + name=kwargs["name"], + label=kwargs["label"], + adapter_factory=kwargs["adapter_factory"], + check_fn=kwargs["check_fn"], + required_env=[], + max_message_length=kwargs.get("max_message_length", 0), + emoji=kwargs.get("emoji", "🔌"), + platform_hint=kwargs.get("platform_hint", ""), + ) + ) + + +class _ProbeAdapter(plugin_adapter.get_adapter_class()): + """MeshtasticAdapter that records inbound MessageEvents instead of + dispatching to a Hermes agent handler (no agent runs in these tests).""" + + def __init__(self, config): + super().__init__(config) + self.received_events = [] + + async def handle_message(self, event): + self.received_events.append(event) + # Intentionally do not call super(): the real base would forward to a + # message handler that only exists inside a running Hermes gateway. + return None + + +@pytest.fixture(scope="module") +def adapter(): + _register_meshtastic_platform() + cfg = PlatformConfig( + enabled=True, + extra={ + "host": E2E_HOST, + "port": NODE0_PORT, + "channel_index": E2E_CHANNEL_INDEX, + }, + ) + return _ProbeAdapter(cfg) + + +@pytest.fixture(scope="module") +def loop(): + """Long-lived event loop in a background thread. + + The adapter mirrors the Hermes gateway contract: connect()/watchdog/ + handle_message() assume a *running* loop (the gateway's). A per-call + asyncio.run() would close the loop right after connect() and strand both + the watchdog and inbound dispatch, so the e2e module keeps one loop alive + and drives coroutines into it with run_coroutine_threadsafe. + """ + import threading + + _loop = asyncio.new_event_loop() + thread = threading.Thread(target=_loop.run_forever, daemon=True) + thread.start() + try: + yield _loop + finally: + # Cancel leftover tasks (e.g. a watchdog started by a failed connect + # whose disconnect teardown never ran) before closing the loop. + async def _shutdown(): + for task in asyncio.all_tasks(_loop): + task.cancel() + + try: + asyncio.run_coroutine_threadsafe(_shutdown(), _loop).result(timeout=5) + except Exception: + pass + _loop.call_soon_threadsafe(_loop.stop) + thread.join(timeout=5) + + +def _run_in_loop(loop, coro, timeout_s=CONNECT_TIMEOUT_S): + import concurrent.futures + + try: + return asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=timeout_s) + except concurrent.futures.TimeoutError: + pytest.fail(f"coroutine did not finish within {timeout_s}s") + + +@pytest.fixture(scope="module") +def node0(adapter, loop): + """Connect the adapter to simulated node 0 (retrying while the node's TCP + API comes up); disconnect afterwards.""" + import time + + deadline = time.monotonic() + CONNECT_TIMEOUT_S + last_ok = False + while time.monotonic() < deadline: + last_ok = bool(_run_in_loop(loop, adapter.connect())) + if last_ok: + break + time.sleep(3) + assert last_ok, ( + f"adapter failed to connect to simulated node at {E2E_HOST}:{NODE0_PORT} " + "(is the simulator running and the node reachable?)" + ) + try: + yield adapter + finally: + try: + _run_in_loop(loop, adapter.disconnect(), timeout_s=10) + except Exception: # loop teardown must not mask test failures + pass + + +def _wait_for(predicate, timeout_s=15.0, interval_s=0.5, what="condition"): + """Poll until *predicate* (callable -> bool) is true, else pytest.fail.""" + import time + + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(interval_s) + pytest.fail(f"timed out waiting for {what}") + + +# --------------------------------------------------------------------------- +# Radio-level flows +# --------------------------------------------------------------------------- + + +def test_adapter_receives_broadcast_from_peer(node0): + """Peer node 1 broadcasts text over the simulated mesh; the adapter must + receive it as a real gateway MessageEvent.""" + import time + + from meshtastic import tcp_interface + + sent = f"e2e-inbound-{int(time.time())}" + + peer = tcp_interface.TCPInterface(hostname=E2E_HOST, portNumber=NODE1_PORT) + try: + time.sleep(RADIO_SETTLE_S) + + def _got(): + return any(getattr(e, "text", None) == sent for e in node0.received_events) + + # Radio delivery is not guaranteed per attempt; retry a few times. + # (meshtastic client >=2.8: sendText lives on the Interface, not Node.) + for _ in range(3): + if _got(): + break + peer.sendText( + text=sent, destinationId="^all", channelIndex=E2E_CHANNEL_INDEX + ) + time.sleep(1) + _wait_for(_got, timeout_s=25, what="inbound broadcast from peer node") + finally: + peer.close() + + +def test_adapter_chunked_send_reaches_peer(node0, loop): + """A long outbound message must be chunked and arrive at the peer node.""" + import time + + from meshtastic import tcp_interface + from pubsub import pub + + content = " ".join(f"word{n}" for n in range(60)) # well over 180 chars -> chunks + received = [] + + def _on_receive(packet, interface): + decoded = packet.get("decoded", {}) or {} + text = decoded.get("text", "") + if text: + received.append(text) + + peer = tcp_interface.TCPInterface(hostname=E2E_HOST, portNumber=NODE1_PORT) + try: + time.sleep(RADIO_SETTLE_S) + pub.subscribe(_on_receive, "meshtastic.receive.text") + + async def _send(): + result = await node0.send("channel_e2e", content) + assert result.success, f"adapter.send failed: {result.error}" + + _run_in_loop(loop, _send(), timeout_s=60) + + def _all_chunks_arrived(): + # Adapter frames multi-part sends as "(i/n) "; the final + # chunk carries the message tail. + return any(str(r).rstrip().endswith("word59") for r in received) + + _wait_for(_all_chunks_arrived, timeout_s=45, what="chunked outbound send at peer") + + except Exception as exc: + # Fast-fail diagnostics: what arrived at the peer, and what the + # simulator itself logged (RF forwarding/routing noise). + tail = "" + try: + sim_log = os.path.join(os.getcwd(), "..", "..", "meshtasticator-sim.log") + if not os.path.exists(sim_log): + sim_log = os.path.join(os.getcwd(), "meshtasticator-sim.log") + if os.path.exists(sim_log): + tail = "\n".join( + open(sim_log, errors="replace").read().splitlines()[-40:] + ) + except OSError: + pass + raise AssertionError( + f"{exc}\nreceived at peer so far: {received[-10:]!r}\n" + f"--- meshtasticator-sim.log tail ---\n{tail}" + ) from exc + finally: + pub.unsubscribe(_on_receive, "meshtastic.receive.text") + peer.close() diff --git a/scripts/meshtasticator_e2e/README.md b/scripts/meshtasticator_e2e/README.md new file mode 100644 index 0000000..78e3710 --- /dev/null +++ b/scripts/meshtasticator_e2e/README.md @@ -0,0 +1,146 @@ +# Meshtasticator e2e — radio-level validation for hermes-meshtastic + +This directory is the **radio-level counterpart** to the contract suite +(`plugin/tests/contract`). The contract suite guarantees the adapter matches +the Hermes gateway API; it cannot prove the adapter actually talks to a +Meshtastic node. These e2e tests boot the +[Meshtasticator](https://meshtastic.org/docs/software/meshtasticator/) +interactive simulator — which runs the **real MeshtasticD device software** +per node with a simulated LoRa PHY — and drive the adapter against a live +simulated node. + +| Layer | Validated by | +|---|---| +| Adapter ↔ Hermes gateway API | `plugin/tests/contract` (no deployment) | +| Adapter ↔ real MeshtasticD TCP/protobuf session | **this suite** | +| Inbound text across the (simulated) LoRa mesh | **this suite** | +| Outbound chunked sends arriving at another node | **this suite** | +| True RF (silicon LoRa, real propagation, duty cycle) | physical radio only | + +## Layout + +``` +scripts/meshtasticator_e2e/ + run_e2e.sh orchestrator: boots N nodes, runs pytest, tears down + tcp_forward.py localhost->daemon TCP forward (remote docker daemons) + README.md this file +plugin/tests/e2e/ + test_meshtasticator_e2e.py the e2e assertions (skip-gated) +.gitea/workflows/ + meshtasticator-e2e.yaml on-demand CI job (docker-enabled runner) +``` + +## Prerequisites + +* **uv** + Python 3.13. +* A contract venv with the real gateway **and** the plugin (with deps) installed: + ```bash + scripts/contract-test.sh # creates plugin/.venv-contract (hermes-agent pinned) + uv pip install --python plugin/.venv-contract/bin/python -e plugin + ``` + (`-e plugin` installs the meshtastic client library the tests also use.) +* A clone of Meshtasticator: `git clone https://github.com/meshtastic/meshtasticator` +* Runtime for the simulator — pick one: + * **Docker** (simplest, works on macOS/Windows): the simulator pulls the + `meshtastic/meshtasticd` image on first run (slow once). Node TCP API ports + are published on your docker host's localhost, which is what the simulator + itself expects. + * **Linux native**: build MeshtasticD's `native` PlatformIO target and pass + the build dir with `--program`. +* Optional but recommended: an isolated simulator venv, because Meshtasticator + pins old client libs (`meshtastic~=2.6.1` in its requirements.txt): + ```bash + uv venv --python 3.13 /tmp/sim-venv + uv pip install --python /tmp/sim-venv/bin/python \ + -r /requirements.txt docker + export SIM_VENV=/tmp/sim-venv # else the script uses plain `python3` + ``` + +## Run + +```bash +# Docker mode (default) +MESHTASTICATOR_DIR=/path/to/meshtasticator \ + scripts/meshtasticator_e2e/run_e2e.sh + +# Linux native mode +MESHTASTICATOR_DIR=/path/to/meshtasticator \ + scripts/meshtasticator_e2e/run_e2e.sh \ + --mode native --program /path/to/firmware/.pio/build/native + +# Tune the scenario + ... --nodes 3 --channel-index 0 --host 127.0.0.1 +``` + +The orchestrator boots the simulator, waits until `N` node TCP API ports accept +connections (Meshtasticator assigns node *n* → port `4404 + n`), then runs +`pytest plugin/tests/e2e` with `MESHTASTICATOR_E2E=1`. Simulator output lands in +`meshtasticator-sim.log` at the repo root. + +**Remote docker daemon** (`DOCKER_HOST=tcp://host:2375`): the simulator's own +control connections assume nodes listen on localhost, so the script starts a +`tcp_forward.py` per node port (localhost → daemon host) before booting the +simulator. Nothing is forwarded when the daemon is local (unset/unix +`DOCKER_HOST`). Readiness is probed on the *upstream* host (the daemon), never +on the forwarders themselves. + +Other headless adaptations the script makes automatically: +* patches `lib/gui.py` in the pinned Meshtasticator checkout: forces the Agg + backend (the code calls `matplotlib.use("TkAgg")` at import) and makes + `move_figure()` a no-op when no Tk window manager exists (its unconditional + `canvas.manager.window` access crashes under Agg at Graph construction). + Only the interactive GUI modes, never used by this harness, are lost, +* warms the `meshtastic/meshtasticd` image (`docker pull`) before booting the + simulator so the pull is not on the simulator's node-boot critical path, +* feeds the simulator's interactive `cmdloop` from a FIFO held open until + teardown — an EOF stdin (e.g. CI) would otherwise make it exit right after + booting the nodes. + +Without `MESHTASTICATOR_E2E=1` the e2e module skips, so ordinary unit/CI runs +stay fast and gateway-free. + +## CI + +`.gitea/workflows/meshtasticator-e2e.yaml` runs the whole flow on a +docker-enabled runner (on demand via `workflow_dispatch` until validated, then +flip on the commented `pull_request` trigger): it builds the Hermes contract +env (pinned like `release.yaml`), installs the plugin with deps, clones +Meshtasticator at a pinned commit, installs the simulator's own requirements in +an isolated venv, and calls `run_e2e.sh --mode docker`. Simulator logs print on +failure. Only one e2e run at a time (the sim hardcodes the `Meshtastic` +container/volume name on the shared daemon). + +## What the tests do + +1. **Inbound**: peer node 1 broadcasts text; the adapter (connected to node 0) + must receive it as a real gateway `MessageEvent` (`host/port/channel_index` + come from `E2E_PORTS`/`E2E_HOST`/`E2E_CHANNEL_INDEX`). +2. **Outbound**: the adapter sends a >180-char message; it must be chunked and + the final chunk (`(i/n) … word59`) must arrive at peer node 1. + +A `_ProbeAdapter` subclass records inbound events instead of dispatching to a +Hermes agent handler (no agent runs in these tests; dispatch is already covered +by the contract suite). + +## Status / open items (v1 scaffold) + +This is an initial, structurally validated scaffold. A full green run still +needs to happen on a Linux/Docker host with a MeshtasticD build, and these +items are expected to need iteration there: + +* **Node adjacency**: the simulator's default random placement may put nodes out + of range. For deterministic tests, seed close-together coordinates (the sim + supports `--from-file` with an `out/nodeConfig.yaml`). +* **Channel semantics**: tests default to channel index 0 (stock primary + channel). Mirroring production (channel 1 / private channel) requires + configuring matching channels on the simulated nodes. +* **Reconnect flow**: killing/restarting a single simulated node needs per-node + lifecycle control (native mode subprocesses; docker mode currently runs all + nodes in one container), so the adapter's watchdog reconnect is **not** yet + exercised here. See the watchdog unit coverage in the adapter for now. +* **CI validation run**: `.gitea/workflows/meshtasticator-e2e.yaml` is drafted + and on-demand only; it still needs a first green run on the docker-enabled + runner (topology assumptions: docker reachable from the job, node ports on + the job's localhost via the automatic tcp forwards). Trigger it via + `workflow_dispatch`, watch the sim log step, and only then enable the + `pull_request` trigger. diff --git a/scripts/meshtasticator_e2e/run_e2e.sh b/scripts/meshtasticator_e2e/run_e2e.sh new file mode 100755 index 0000000..df34a94 --- /dev/null +++ b/scripts/meshtasticator_e2e/run_e2e.sh @@ -0,0 +1,297 @@ +#!/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 diff --git a/scripts/meshtasticator_e2e/tcp_forward.py b/scripts/meshtasticator_e2e/tcp_forward.py new file mode 100644 index 0000000..b543120 --- /dev/null +++ b/scripts/meshtasticator_e2e/tcp_forward.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Minimal TCP forwarder used by run_e2e.sh when the Docker daemon is remote. + +Meshtasticator's interactive simulator always connects to its simulated nodes +at localhost: (lib/interactive.py init_communication). On a Gitea +Actions runner whose Docker daemon is remote (e.g. DOCKER_HOST=tcp://docker.local:2375) +the container-published node ports live on the *daemon* host, not the job's +localhost — so we bind 127.0.0.1: in the job and forward to the daemon +host, restoring the localhost view the simulator (and our e2e tests) expect. + +Usage: tcp_forward.py --listen 127.0.0.1:4404 --target docker.local:4404 +""" + +import argparse +import socket +import threading + + +def _forward(src: socket.socket, dst: socket.socket): + try: + while True: + data = src.recv(65536) + if not data: + break + dst.sendall(data) + except OSError: + pass + finally: + try: + dst.shutdown(socket.SHUT_WR) + except OSError: + pass + + +def _handle(client: socket.socket, target_addr): + try: + upstream = socket.create_connection(target_addr, timeout=10) + except OSError: + client.close() + return + t = threading.Thread(target=_forward, args=(client, upstream), daemon=True) + t.start() + _forward(upstream, client) + client.close() + upstream.close() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--listen", required=True, help="local bind, e.g. 127.0.0.1:4404") + ap.add_argument("--target", required=True, help="forward target, e.g. docker.local:4404") + args = ap.parse_args() + + lhost, lport = args.listen.rsplit(":", 1) + thost, tport = args.target.rsplit(":", 1) + target_addr = (thost, int(tport)) + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((lhost, int(lport))) + listener.listen(16) + print(f"forwarding {args.listen} -> {args.target}", flush=True) + + while True: + client, _ = listener.accept() + threading.Thread(target=_handle, args=(client, target_addr), daemon=True).start() + + +if __name__ == "__main__": + main()