* 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>
348 lines
14 KiB
Python
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"
|
|
)
|