Files
firmware/mcp-server/tests/mesh/test_position.py
T

131 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Mesh: a fixed-position broadcast from TX arrives decoded on RX.
Exercises ``POSITION_APP`` (portnum 3) — the core Meshtastic portnum that
map apps and fleet dashboards depend on. Without real GPS hardware on
either device we put TX into **fixed-position** mode with a short
broadcast interval; the firmware's ``PositionModule::runOnce`` then
emits a Position packet every N seconds independent of GPS state. RX
listens on ``meshtastic.receive.position`` and verifies the decode.
Scope: validating the *routing + decode* path through
``modules/PositionModule.cpp``, not GPS accuracy. We don't set or assert
any specific lat/lon — the test is "a Position protobuf rode the wire
and was decoded correctly", which is what a downstream map client needs.
Why directed/PKI warmup matters: the config writes (``set_config``) are
directed admin sends. They don't fire unless the firmware trusts the
sender — same PKI staleness trap as the other directed-send tests, so
we do the bilateral warmup first.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin
from ._receive import ReceiveCollector, nudge_nodeinfo_port
def _restore_position_config(port: str, orig: dict[str, Any]) -> None:
"""Best-effort teardown: restore the 3 config fields this test mutates.
Runs in a ``finally`` block — if the test failed mid-way we still
want the device back to its pre-test state so downstream tests
don't inherit a short broadcast interval + fixed-position flag.
Swallows exceptions to avoid masking the underlying assertion.
"""
for path, value in (
("position.fixed_position", orig.get("fixed_position", False)),
(
"position.position_broadcast_secs",
orig.get("position_broadcast_secs", 0),
),
(
"position.position_broadcast_smart_enabled",
orig.get("position_broadcast_smart_enabled", True),
),
):
try:
admin.set_config(path, value, port=port)
except Exception:
pass
@pytest.mark.timeout(240)
def test_position_broadcast_and_receive(mesh_pair: dict[str, Any]) -> None:
"""Runs for every directed pair. TX emits a periodic Position; RX
receives and decodes it via the ``receive.position`` pubsub topic.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
tx_node_num = mesh_pair["tx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
assert tx_node_num is not None, f"{tx_role} my_node_num missing"
# Snapshot current position config so teardown can restore.
orig_position = (
admin.get_config("position", port=tx_port).get("config", {}).get("position", {})
)
try:
# Configure TX for periodic fixed-position broadcasts. 30 s is a
# compromise: short enough for the test to finish inside the
# 240 s timeout, long enough that one dropped broadcast still
# leaves a 90 s window for the next. smart_enabled=False
# disables the distance/interval throttle in
# `PositionModule::hasChanged` so we get broadcasts on a fixed
# cadence instead of "only when position changes".
admin.set_config("position.fixed_position", True, port=tx_port)
admin.set_config("position.position_broadcast_secs", 30, port=tx_port)
admin.set_config(
"position.position_broadcast_smart_enabled", False, port=tx_port
)
# Small settle: the firmware applies config writes asynchronously,
# and the next broadcast is scheduled off the *new* interval. A
# brief pause keeps a racy first-broadcast-at-the-old-interval
# from confusing the listener.
time.sleep(2.0)
# Bilateral PKI warmup — broadcast packets aren't PKI-encrypted
# (they use channel keys), but we also want both sides in each
# other's node tables so `PositionModule::hasChanged` doesn't
# suppress the broadcast as "no peers to tell". NodeInfo pings
# achieve both.
nudge_nodeinfo_port(tx_port)
nudge_nodeinfo_port(rx_port)
time.sleep(3.0)
with ReceiveCollector(rx_port, topic="meshtastic.receive.position") as rx:
got = rx.wait_for(
lambda pkt: pkt.get("from") == tx_node_num,
timeout=120.0, # 2× the broadcast_secs for one retransmit
)
assert got is not None, (
f"no Position packet from {tx_role} (0x{tx_node_num:08x}) within "
f"120 s; RX ({rx_role}) saw {len(rx.snapshot())} position packet(s) "
f"from {[hex(p.get('from') or 0) for p in rx.snapshot()]!r}. "
f"Check that TX's PositionModule is enabled and the broadcast "
f"schedule isn't throttled elsewhere."
)
# Validate the decode path. `decoded.position` is the
# MessageToDict version of the Position proto; the protobuf
# serializer strips default-valued optional fields, so we can't
# rely on lat/lon being present (fixed-position without stored
# coords is a valid zero-state). What we CAN rely on is the
# outer `position` sub-dict existing and the `raw` protobuf
# parsing cleanly — those prove the POSITION_APP portnum
# decoded without errors.
decoded = got.get("decoded", {})
assert "position" in decoded, (
f"packet from {tx_role} had no `decoded.position` — "
f"POSITION_APP decode failed. decoded={decoded!r}"
)
finally:
_restore_position_config(tx_port, orig_position)