mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-16 08:31:35 +10:00
131 lines
5.6 KiB
Python
131 lines
5.6 KiB
Python
"""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)
|