Files
meshtastic_firmware/mcp-server/tests/mesh/test_nexthop_multihop_recovery.py
T
22072c5f4b Pr1.5 tmm nexthop (#10745)
* TrafficManagement: flat unified cache + persistent next-hop overflow store

Reworks the TrafficManagementModule cache layer (policing behaviour unchanged
from upstream) and adds a routing-hint overflow store:

- Flatten the ring: replace the cuckoo-hashed unified cache and the bucketed
  PSRAM NodeInfo index with plain flat arrays + linear scan (same idiom as
  WarmNodeStore). At LoRa packet rates an O(n) scan of the cache is negligible,
  and it removes a large amount of hashing/displacement complexity. The cache
  entry is 11 B; timestamps use a uniform +1 presence-offset so a 0 byte always
  means "empty" across every sub-store. Adds rebaseEpoch() so cached state
  survives the ~19 h relative-timestamp horizon instead of being flushed.

- Next-hop overflow cache: setNextHop/getNextHopHint store a confirmed last-byte
  relay for a destination, written only from NextHopRouter's ACK-confirmed
  decision (and mirrored from TraceRoute). NextHopRouter::getNextHop falls back
  to this cache when the hot NodeDB has no hint, so DMs/relays to long-tail
  nodes keep routing after the node ages out of NodeInfoLite.

- Persistence: preloadNextHopsFromNodeDB warm-starts the cache from persisted
  NodeInfoLite hints on first maintenance pass; next_hop entries are kept alive
  across the maintenance sweep (no TTL) and never clobbered by a stale preload.

All packet-policing logic (rate limit, position dedup, unknown-packet drop,
NodeInfo direct response, hop exhaustion) is the existing upstream behaviour,
untouched. HAS_TRAFFIC_MANAGEMENT defaults on so the module is compiled in. (see note).

Tests: upstream policing suite now actually runs (adds the MeshTypes.h include
that gates HAS_TRAFFIC_MANAGEMENT) plus 4 next-hop tests. Role-aware throttles,
politeness, precision clamp, port-interval and mesh-radius gating — and the
rate-limit >255 saturation fix — are deferred to the advanced-TMM branch.

Note: default dedup movement grid moves to ~91m, which also means 1.5km required to end up with the same signature position - coarser and therefore further than before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* TrafficManagement: fix cppcheck constVariablePointer warning

`node` in preloadNextHopsFromNodeDB() is never written through — mark
it const to satisfy cppcheck's constVariablePointer check in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add multi-hop NextHop recovery tests and unit tests for routing reliability

- Introduced a new test suite for multi-hop NextHop directed-message delivery and relay recovery in `test_nexthop_multihop_recovery.py`. This includes tests for end-to-end delivery and recovery after relay drop.
- Implemented unit tests in `test_main.cpp` for NextHop routing reliability mitigations, covering:
  - M1: Ambiguity-aware last-byte resolution.
  - M2: NextHopRouter's strict-neighbor gate and hop limit checks.
  - M3: Route-health freshness and failure decay.
- Enhanced mock classes to facilitate controlled testing of node behaviors and routing logic.

* grafting fixed

* Address Copilot review for PR #10735 (NextHop improvements)

- docs/nexthop-routing-reliability.md: update status from "no code
  changes yet" to reflect that mitigations and tests are implemented

RAM pressure and MIGRATION_VERBOSE concerns addressed upstream in
PR2.5 (per-platform TRAFFIC_MANAGEMENT_CACHE_SIZE) and PR2 (verbose
default=0) respectively; (0,0) sentinel fixed in PR2.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* CI: fix cppcheck constVariablePointer and test include path

- NextHopRouter.cpp: qualify two RouteHealth *h locals as const — only
  read for stale-route checks, never mutated through the pointer
- Router.cpp: qualify meshtastic_NodeInfoLite *node as const in
  shouldDecrementHopLimit — only read for favorite/role predicate
- test_position_module/test_main.cpp: change bare PositionModule.h to
  modules/PositionModule.h — build_flags sets -Isrc, not -Isrc/modules,
  so the bare form fails to resolve in the native PlatformIO test env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WarmStore: cache device role + protected category in last_heard low bits

Steal the low 6 bits of WarmNodeEntry.last_heard to carry an evicted node's
device role (4 bits) and a protected category (2 bits) for the hop-trim path,
at zero record-size cost (entry stays 40 B; no RAM/flash growth). The high bits
remain a real unix-seconds timestamp, quantised to 64 s — ample for warm LRU
ordering of long-tail nodes.

- absorb() packs role/protectedCat; place()/ring replay store the raw word so
  metadata round-trips through flash. LRU compares masked time (warmTimeOf).
- take() rehydration masks the metadata bits and restores the cached role so a
  re-admitted node isn't stuck at CLIENT until its next NodeInfo.
- NodeDB classifies the category (favorite/ignored/verified -> Flag;
  tracker/sensor/tak_tracker -> Role) at each eviction site.
- WarmNodeStore::lookupMeta() exposes role/category to consumers.
- Bump WARM_RING_MAGIC (WRNG->WRN2): old rings read as erased and rebuild;
  warm data is a non-critical evictee cache, so discard-on-upgrade is safe.

Tests: test_warm_store 11/11 (new meta round-trip + quantisation-aware ordering);
NodeDB compiles (test_nodedb_blocked 4/4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: migrate v1 rings/files by discarding last_heard, not the data

Previously the WRNG->WRN2 magic bump treated old rings as erased, discarding all
warm entries — including the PKI public keys that let evicted nodes keep
decrypting DMs. Instead, read v1 (WRNG / WRM1) records and keep each node's
identity + public key, discarding only last_heard (its low bits would otherwise
be misread as the new role/protected metadata). Records re-rank and re-learn
their role on next contact.

- Ring backend (nRF52840): ringReadHeader accepts both magics and reports v1 via
  an out-param; replay zeroes last_heard for v1 records. If the active head page
  is v1, force a rotation so new v2 records never land in a v1-headered page
  (which would discard their freshly-set role on the next load). Legacy pages
  convert to v2 as the ring rotates.
- File backend (warm.dat): bump WARM_STORE_MAGIC WRM1->WRM2; accept WRM1, verify
  CRC against the stored bytes, then discard last_heard and mark dirty so the
  next save rewrites as v2.

Tests: test_warm_store 12/12 (adds test_ws_v1_migration_discardsLastHeard:
key survives, role/protected reset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: guard role bit-width + test eviction carries role/protected

- static_assert that the device role enum still fits the 4-bit warm metadata
  field (WARM_ROLE_MASK); fails the build loudly if a new role is added past 15
  rather than silently truncating role on eviction. (Max role today = 12.)
- Add test_migration_carriesRoleAndProtectedIntoWarm: a demoted TRACKER lands in
  the warm tier with its key, role=TRACKER and protected category=Role; a demoted
  CLIENT carries role=CLIENT/None. Exercises the NodeDB eviction path +
  warmProtectedCategory classification (the warm-store unit tests only cover
  absorb() directly).

Tests: test_nodedb_blocked 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix copilot comments

* fix(test): restore #if HAS_TRAFFIC_MANAGEMENT guard in TMM test

The rebase onto PR1.5 lost the top-level HAS_TRAFFIC_MANAGEMENT guard
that PR1.5 introduced, leaving the #else/#endif tail orphaned and
causing compile errors on non-TMM builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-19 19:52:58 -05:00

348 lines
14 KiB
Python

"""Multi-hop NextHop directed-message delivery + relay-recovery (bench test).
This is the hardware/tier-3 validator for the NextHop DM reliability work
(see `docs/nexthop-routing-reliability.md`). The unit suite
`test/test_nexthop_routing` covers the routing *logic* exhaustively; this test
covers the *end-to-end* multi-hop behavior that only a real (or RF-separated)
mesh exercises:
* a directed DM that must traverse a relay is delivered (next_hop routing +
the M1/M2 ambiguity gate + M3 route learning all engage), and
* when the established relay drops and returns, delivery recovers rather than
black-holing (the M3 stale-route decay / re-learn path).
TOPOLOGY REQUIREMENT — why this usually SKIPS:
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
Three co-located radios all hear each other, so A→C is a single direct hop and
next_hop never engages. To run this test the bench must be a *line* — A — B — C
— with the endpoints out of each other's direct RF range (physical distance or
attenuators). The `multihop_topology` fixture detects this automatically: it
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
file is safe to commit and run anywhere — it only *asserts* when the topology
genuinely requires a relay.
REQUIREMENTS:
* ≥3 baked devices. The default hub profile is 2 roles (nrf52, esp32s3); add a
third via `--hub-profile=path/to/hub.yaml` (see conftest `hub_profile`).
* The relay-recovery test additionally needs uhubctl + a power-controllable
relay port (same gate the other power tests use).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from tests import _power
from tests._port_discovery import resolve_port_by_role
from ._receive import ReceiveCollector, nudge_nodeinfo, nudge_nodeinfo_port
def _hops_away(rec: dict[str, Any]) -> int | None:
"""Read a node's hop distance from a `nodesByNum` entry, tolerating either
the camelCase (`hopsAway`) or snake_case (`hops_away`) spelling depending on
the meshtastic-python version."""
for key in ("hopsAway", "hops_away"):
val = rec.get(key)
if isinstance(val, int):
return val
return None
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
"""Flood a fresh NodeInfo from every node so the whole mesh (including
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
distances. Best-effort — a single node failing to nudge shouldn't abort."""
for _ in range(rounds):
for port in ports:
try:
nudge_nodeinfo_port(port)
except Exception: # noqa: BLE001 — warmup is best-effort
pass
time.sleep(0.5)
time.sleep(settle)
def _wait_for_pubkey(
tx_iface: Any, rx_num: int, rx_port: str, deadline_s: float = 90.0
) -> bool:
"""Block until `tx_iface` holds `rx_num`'s public key (directed PKI sends
NAK without it). Re-nudges both sides periodically; multi-hop warmup is
slower than the 2-device case because NodeInfo must be relayed, hence the
longer default deadline."""
deadline = time.monotonic() + deadline_s
last_nudge = time.monotonic()
while time.monotonic() < deadline:
rec = (tx_iface.nodesByNum or {}).get(rx_num, {})
if rec.get("user", {}).get("publicKey"):
return True
if time.monotonic() - last_nudge > 20.0:
nudge_nodeinfo_port(rx_port)
nudge_nodeinfo(tx_iface)
last_nudge = time.monotonic()
time.sleep(1.0)
return False
def _traceroute_route(tx_port: str, rx_num: int, rx_port: str) -> list[int] | None:
"""Run a traceroute TX→RX and return the forward `route` (list of relay node
numbers), or None if it couldn't be obtained. Mirrors test_traceroute's
request/PKI/retry pattern."""
from meshtastic.mesh_interface import MeshInterface
with ReceiveCollector(tx_port, topic="meshtastic.receive.traceroute") as tx:
nudge_nodeinfo_port(rx_port)
tx.broadcast_nodeinfo_ping()
if not _wait_for_pubkey(tx._iface, rx_num, rx_port, 60.0):
return None
for _attempt in range(2):
try:
tx._iface.sendTraceRoute(dest=rx_num, hopLimit=5)
break
except MeshInterface.MeshInterfaceError:
time.sleep(5.0)
else:
return None
pkt = tx.wait_for(lambda p: p.get("from") == rx_num, timeout=8.0)
if pkt is None:
return None
tr = (pkt.get("decoded", {}) or {}).get("traceroute") or {}
return [int(n) for n in (tr.get("route") or [])]
@pytest.fixture(scope="session")
def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
"""Discover a real multi-hop pier (tx → relay → rx) on the bench, or skip.
Returns {tx_role, tx_port, rx_role, rx_port, rx_num, relay_role, relay_num}.
"""
roles = sorted(baked_mesh)
if len(roles) < 3:
pytest.skip(
"multi-hop NextHop test needs ≥3 baked devices arranged as a line "
"(endpoints out of direct RF range). Add a third role via "
f"--hub-profile. Detected roles: {roles}"
)
by_role = {r: (baked_mesh[r]["port"], baked_mesh[r]["my_node_num"]) for r in roles}
if any(num is None for _, num in by_role.values()):
pytest.skip("a baked device is missing my_node_num; can't map the topology")
_warm_mesh([port for port, _ in by_role.values()])
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
multihop_pair: tuple[str, str] | None = None
for a_role in roles:
a_port, _ = by_role[a_role]
try:
with connect(port=a_port) as a_iface:
nodes = a_iface.nodesByNum or {}
except Exception: # noqa: BLE001
continue
for c_role in roles:
if c_role == a_role:
continue
_, c_num = by_role[c_role]
hops = _hops_away(nodes.get(c_num, {}))
if hops is not None and hops >= 1:
multihop_pair = (a_role, c_role)
break
if multihop_pair:
break
if not multihop_pair:
pytest.skip(
"no multi-hop pair found — every device appears to be a direct "
"neighbor. Arrange the bench as a line (A — B — C) with the "
"endpoints out of direct RF range (distance or attenuators) so a "
"relay is actually required, then re-run."
)
a_role, c_role = multihop_pair
a_port, _ = by_role[a_role]
c_port, c_num = by_role[c_role]
route = _traceroute_route(a_port, c_num, c_port)
if not route:
pytest.skip(
f"{a_role}{c_role} looked multi-hop but traceroute returned no "
"intermediate relay; can't identify the relay node to drive the "
"recovery test"
)
relay_num = route[0]
relay_role = next((r for r in roles if by_role[r][1] == relay_num), None)
return {
"tx_role": a_role,
"tx_port": a_port,
"rx_role": c_role,
"rx_port": c_port,
"rx_num": c_num,
"relay_role": relay_role,
"relay_num": relay_num,
}
@pytest.mark.timeout(300)
def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
"""A directed wantAck DM that must traverse the relay is delivered.
Exercises the NextHop routing path end-to-end: TX picks a next hop toward
RX (M2 gate), the relay resolves the next_hop byte and forwards (M1), and
the route is learned from the returning ACK (M3). Retries absorb transient
LoRa loss; the assertion is on eventual delivery.
"""
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
relay_role = multihop_topology["relay_role"]
unique = f"nexthop-mh-{tx_role}-to-{rx_role}-{int(time.time())}"
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip(
f"{tx_role} never learned {rx_role}'s pubkey over the relay; "
"multi-hop PKI warmup didn't complete"
)
got = None
for _attempt in range(3):
pkt = tx_iface.sendText(unique, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == unique,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(5.0)
assert got is not None, (
f"multi-hop directed DM {tx_role}{rx_role} via relay "
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
)
@pytest.mark.timeout(600)
def test_multihop_relay_recovery(
multihop_topology: dict[str, Any],
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
) -> None:
"""Delivery recovers after the established relay drops and returns.
Establishes a baseline DM (route via relay learned), powers the relay OFF
(confirming TX survives sending across a downed relay), then powers it back
ON and asserts directed delivery resumes — the M3 stale-route decay /
re-learn path. With a strict A — B — C line there is no path while B is down,
so we only assert TX doesn't crash during the outage; the delivery assertion
is after B returns.
"""
relay_role = multihop_topology["relay_role"]
if not relay_role:
pytest.skip(
"relay node isn't one of the baked hub roles, so it can't be "
"power-cycled; recovery test needs a controllable relay"
)
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
base = f"mh-recover-base-{int(time.time())}"
post = f"mh-recover-post-{int(time.time())}"
# Baseline: confirm delivery works (so the route via the relay is learned)
# before we perturb anything — otherwise a later failure is ambiguous.
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip("multi-hop PKI warmup failed; can't run recovery test")
tx_iface.sendText(base, destinationId=rx_num, wantAck=True)
assert (
rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
)
is not None
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
# Power the relay OFF.
try:
_power.power_off(relay_role)
_power.wait_for_absence(relay_role, timeout_s=15.0)
except Exception as exc: # noqa: BLE001
try:
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001
pass
pytest.skip(f"can't power-control relay {relay_role!r}: {exc}")
# With the only relay down there's no path; we just confirm TX accepts the
# send and survives its internal retries (it must not crash / wedge).
try:
with connect(port=tx_port) as tx_iface:
pkt = tx_iface.sendText(
f"mh-while-down-{int(time.time())}",
destinationId=rx_num,
wantAck=True,
)
assert pkt is not None
time.sleep(8.0) # let retransmissions + route decay run
except Exception as exc: # noqa: BLE001 — restore bench state before failing
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
raise AssertionError(
f"TX crashed sending across a downed relay: {exc}"
) from exc
# Power the relay back ON and let it re-enumerate + boot.
_power.power_on(relay_role)
time.sleep(0.5)
try:
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
pass
time.sleep(8.0)
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
# Delivery should resume once the relay is back (M3 re-learn / decay path).
got = None
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
_wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0)
for _attempt in range(4):
pkt = tx_iface.sendText(post, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == post,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(6.0)
assert got is not None, (
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}{rx_role} "
"never resumed — stale-route recovery (M3) may be broken"
)