mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
data,web: put the Reticulum host on the map from its RNS config (#991)
This commit is contained in:
14 files changed
+2114
-72
No files matched your search
@@ -0,0 +1,429 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The Reticulum provider publishes its host's position (SPEC RP1-RP6).
|
||||
|
||||
Drives :class:`ReticulumProvider` at connect, through the node snapshot and the
|
||||
self-node report, and end to end through the daemon loop on the fake stack of
|
||||
``tests/test_reticulum_host_refresh.py``: the host's records carry the position
|
||||
from the RNS config, peers never do, every report posts one positions row, and
|
||||
a host with nothing announcing rides on one bare record.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path: # pragma: no cover - conftest adds it
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import data.mesh_ingestor.protocols.reticulum as _mod # noqa: E402
|
||||
import data.mesh_ingestor.protocols.reticulum_position as rp # noqa: E402
|
||||
from daemon_fakes import make_state # noqa: E402 - shared daemon doubles
|
||||
from data.mesh_ingestor import config, handlers, queue # noqa: E402
|
||||
from data.mesh_ingestor.handlers import _state as handler_state # noqa: E402
|
||||
from data.mesh_ingestor.protocols.reticulum import ( # noqa: E402
|
||||
ReticulumProvider,
|
||||
_ReticulumInterface,
|
||||
)
|
||||
from test_reticulum_host_refresh import ( # noqa: E402,F401 - shared fixture
|
||||
_HOST,
|
||||
_WALL_OFFSET,
|
||||
_busy_pass,
|
||||
world,
|
||||
)
|
||||
from test_reticulum_position_unit import ( # noqa: E402 - shared config text
|
||||
_CONFIG,
|
||||
_PASSPHRASE,
|
||||
_SECRET,
|
||||
)
|
||||
from test_reticulum_unit import ( # noqa: E402 - shared Reticulum doubles
|
||||
_FIELD_LXMF,
|
||||
_FIELD_NOMADNET,
|
||||
_FIELD_PRIMARY,
|
||||
_fake_rns,
|
||||
_local_stack,
|
||||
)
|
||||
|
||||
_PEER = "!aabbccdd"
|
||||
"""Node id of the remote peer ``_busy_pass`` lands an announce from."""
|
||||
|
||||
_POSITION = rp.HostPosition(52.5029, 13.4042, 34.0)
|
||||
"""The position :data:`_CONFIG` sets."""
|
||||
|
||||
_WITHOUT_POSITION = "\n".join(
|
||||
line
|
||||
for line in _CONFIG.splitlines()
|
||||
if not line.strip().startswith(("latitude", "longitude", "height"))
|
||||
)
|
||||
""":data:`_CONFIG` with the position keys deleted."""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def quiet(monkeypatch):
|
||||
"""Silence the logs and start each test with no logged position outcome."""
|
||||
monkeypatch.setattr(rp, "_last_outcome", None)
|
||||
monkeypatch.setattr(config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered(monkeypatch):
|
||||
"""Register :data:`_HOST` as the host, restoring the handler state after."""
|
||||
for name in (
|
||||
"_host_node_id",
|
||||
"_host_telemetry_last_rx",
|
||||
"_host_nodeinfo_last_seen",
|
||||
):
|
||||
monkeypatch.setattr(handler_state, name, None)
|
||||
handlers.register_host_node_id(_HOST)
|
||||
|
||||
|
||||
def _positioned_iface() -> _ReticulumInterface:
|
||||
"""Return an interface holding the position read at connect.
|
||||
|
||||
Returns:
|
||||
A fresh :class:`_ReticulumInterface` with :data:`_POSITION`.
|
||||
"""
|
||||
iface = _ReticulumInterface(target=None)
|
||||
iface.host_position = _POSITION
|
||||
return iface
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect: read once, onto the connection (SPEC RP1/RP2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_reads_the_host_position_from_the_rns_config(monkeypatch, tmp_path):
|
||||
"""The position in the first RNode block lands on the new interface."""
|
||||
fake, _state = _fake_rns(existing_instance=object())
|
||||
monkeypatch.setattr(_mod, "RNS", fake)
|
||||
monkeypatch.setattr(config, "RETICULUM_CONFIG_DIR", str(tmp_path))
|
||||
for name in (
|
||||
"RETICULUM_FREQ",
|
||||
"RETICULUM_PRESET",
|
||||
"LORA_FREQ",
|
||||
"MODEM_PRESET",
|
||||
"INGESTOR_NODE_ID",
|
||||
"CONNECTION",
|
||||
):
|
||||
monkeypatch.setattr(config, name, None)
|
||||
|
||||
iface, _target, _next = ReticulumProvider().connect(active_candidate=None)
|
||||
assert iface.host_position is None # no config file: no position
|
||||
|
||||
(tmp_path / "config").write_text(_CONFIG, encoding="utf-8")
|
||||
iface, _target, _next = ReticulumProvider().connect(active_candidate=None)
|
||||
assert iface.host_position == _POSITION
|
||||
assert config.LORA_FREQ == 867 # the radio metadata reads the same block
|
||||
|
||||
|
||||
def test_quotes_and_a_bad_height_still_publish_the_coordinates(
|
||||
monkeypatch, tmp_path, registered
|
||||
):
|
||||
"""Quoted keys read as RNS reads them; a bad height drops the altitude only."""
|
||||
fake, _state = _fake_rns(existing_instance=object())
|
||||
monkeypatch.setattr(_mod, "RNS", fake)
|
||||
monkeypatch.setattr(config, "RETICULUM_CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(config, "INGESTOR_NODE_ID", _HOST)
|
||||
monkeypatch.setattr(config, "CONNECTION", None)
|
||||
posts: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
queue,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, **_k: posts.append((path, payload)),
|
||||
)
|
||||
quoted = (
|
||||
_CONFIG.replace("latitude = 52.5029", 'latitude = "52.5029"')
|
||||
.replace("longitude = 13.4042", "longitude = '13.4042'")
|
||||
.replace("height = 34", "height = sky-high")
|
||||
)
|
||||
(tmp_path / "config").write_text(quoted, encoding="utf-8")
|
||||
provider = ReticulumProvider()
|
||||
iface, _target, _next = provider.connect(active_candidate=None)
|
||||
assert iface.host_position == rp.HostPosition(52.5029, 13.4042, None)
|
||||
position = provider.self_node_items(iface)[0][1]["position"]
|
||||
assert (position["latitude"], position["longitude"]) == (52.5029, 13.4042)
|
||||
assert "altitude" not in position
|
||||
assert "altitude" not in posts[0][1]
|
||||
|
||||
|
||||
def test_removing_the_keys_stops_the_refresh(monkeypatch, tmp_path, registered):
|
||||
"""Delete the keys and reconnect: the reports post no position (SPEC RP8)."""
|
||||
fake, _state = _fake_rns(existing_instance=object())
|
||||
monkeypatch.setattr(_mod, "RNS", fake)
|
||||
monkeypatch.setattr(config, "RETICULUM_CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(config, "INGESTOR_NODE_ID", _HOST)
|
||||
monkeypatch.setattr(config, "CONNECTION", None)
|
||||
posts: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
queue,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, **_k: posts.append((path, payload)),
|
||||
)
|
||||
(tmp_path / "config").write_text(_CONFIG, encoding="utf-8")
|
||||
provider = ReticulumProvider()
|
||||
iface, _target, _next = provider.connect(active_candidate=None)
|
||||
assert provider.self_node_items(iface)[0][1]["position"]["latitude"] == 52.5029
|
||||
assert [path for path, _payload in posts] == ["/api/positions"]
|
||||
|
||||
(tmp_path / "config").write_text(_WITHOUT_POSITION, encoding="utf-8")
|
||||
iface, _target, _next = provider.connect(active_candidate=None)
|
||||
assert iface.host_position is None
|
||||
assert provider.self_node_items(iface) == []
|
||||
assert len(posts) == 1 # no second row
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The node snapshot (SPEC RP4/RP6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_snapshot_positions_the_host_records_and_never_a_peer(monkeypatch, registered):
|
||||
"""Every record keyed on the host carries the position; a peer's never does.
|
||||
|
||||
The host's ``lxmf.delivery`` was also heard live at 0 hops, so one host
|
||||
record comes from the stored announces and one from discovery.
|
||||
"""
|
||||
_local_stack(
|
||||
monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY}
|
||||
)
|
||||
monkeypatch.setattr(_mod.time, "time", lambda: 1_791_000_000.0)
|
||||
iface = _positioned_iface()
|
||||
peer = {"nodeId": _PEER, "user": {"longName": "Remote Peer"}}
|
||||
heard = {"nodeId": _HOST, "lastHeard": 1, "destination": {"id": _FIELD_LXMF}}
|
||||
iface._update_node(_PEER, peer)
|
||||
iface._update_node(_HOST, heard)
|
||||
|
||||
items = ReticulumProvider().node_snapshot_items(iface)
|
||||
|
||||
by_id: dict[str, list[dict]] = {}
|
||||
for node_id, node in items:
|
||||
by_id.setdefault(node_id, []).append(node)
|
||||
assert len(by_id[_HOST]) == 2 # the heard lxmf.delivery, the found nomadnet
|
||||
for node in by_id[_HOST]:
|
||||
assert node["position"] == rp.node_position(_POSITION, 1_791_000_000)
|
||||
assert by_id[_PEER] == [peer]
|
||||
assert "position" not in heard # the stored announce is not mutated
|
||||
|
||||
|
||||
def test_snapshot_is_unchanged_without_a_position(monkeypatch, registered):
|
||||
"""No keys in the RNS config: the records are exactly what they were."""
|
||||
_local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY})
|
||||
items = ReticulumProvider().node_snapshot_items(_ReticulumInterface(target=None))
|
||||
assert len(items) == 1
|
||||
assert "position" not in items[0][1]
|
||||
|
||||
|
||||
def test_a_host_with_nothing_announcing_gets_one_bare_record(monkeypatch, registered):
|
||||
"""Docker's default volume: a pinned host id and no destination (SPEC RP6)."""
|
||||
_local_stack(monkeypatch, {})
|
||||
monkeypatch.setattr(_mod.time, "time", lambda: 1_791_000_000.0)
|
||||
items = ReticulumProvider().node_snapshot_items(_positioned_iface())
|
||||
assert items == [
|
||||
(
|
||||
_HOST,
|
||||
{
|
||||
"nodeId": _HOST,
|
||||
"lastHeard": 1_791_000_000,
|
||||
"protocol": "reticulum",
|
||||
# The node's own placeholder (RA10(a)): the head of its id.
|
||||
"user": {
|
||||
"shortName": "2771",
|
||||
"longName": "Reticulum 2771",
|
||||
"role": "PEER",
|
||||
},
|
||||
"position": rp.node_position(_POSITION, 1_791_000_000),
|
||||
},
|
||||
)
|
||||
]
|
||||
# Without a position the snapshot stays empty, as before.
|
||||
assert (
|
||||
ReticulumProvider().node_snapshot_items(_ReticulumInterface(target=None)) == []
|
||||
)
|
||||
|
||||
|
||||
def test_the_snapshot_posts_no_row(monkeypatch, registered):
|
||||
"""The row is the report's job, so connect posts one row, not two."""
|
||||
_local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY})
|
||||
posts: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
queue, "_queue_post_json", lambda path, *_a, **_k: posts.append(path)
|
||||
)
|
||||
ReticulumProvider().node_snapshot_items(_positioned_iface())
|
||||
assert posts == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The self-node report (SPEC RP4-RP6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_report_positions_the_host_and_posts_its_row(monkeypatch, registered):
|
||||
"""Each report: positioned host records and one positions row."""
|
||||
_local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY})
|
||||
posts: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
queue,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, **_k: posts.append((path, payload)),
|
||||
)
|
||||
items = ReticulumProvider().self_node_items(_positioned_iface())
|
||||
assert [node["destination"]["aspect"] for _nid, node in items] == ["lxmf.delivery"]
|
||||
assert items[0][1]["position"]["altitude"] == 34.0
|
||||
assert [(path, payload["node_id"]) for path, payload in posts] == [
|
||||
("/api/positions", _HOST)
|
||||
]
|
||||
|
||||
|
||||
def test_the_report_still_needs_a_registered_host_id(monkeypatch):
|
||||
"""No host id yet: nothing to report, positioned or not."""
|
||||
monkeypatch.setattr(handler_state, "_host_node_id", None)
|
||||
assert ReticulumProvider().self_node_items(_positioned_iface()) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End to end through the daemon loop (SPEC RP4-RP6, ACCEPTANCE RP-A3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mesh(world, monkeypatch, tmp_path):
|
||||
"""The RE-A14 world with the host position in its RNS config.
|
||||
|
||||
Returns:
|
||||
The ``world`` namespace plus ``posts``: every ``(path, payload)``
|
||||
queued, in order.
|
||||
"""
|
||||
(tmp_path / "config").write_text(_CONFIG, encoding="utf-8")
|
||||
posts: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
queue,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, **_k: posts.append((path, payload)),
|
||||
)
|
||||
world.posts = posts
|
||||
return world
|
||||
|
||||
|
||||
def _records(posts, node_id: str) -> list[dict]:
|
||||
"""Return every posted node record of *node_id*, in order.
|
||||
|
||||
Parameters:
|
||||
posts: Captured ``(path, payload)`` pairs.
|
||||
node_id: Node whose records to collect.
|
||||
|
||||
Returns:
|
||||
The node dicts.
|
||||
"""
|
||||
return [p[node_id] for path, p in posts if path == "/api/nodes" and node_id in p]
|
||||
|
||||
|
||||
def _rows(posts) -> list[dict]:
|
||||
"""Return every posted positions row, in order.
|
||||
|
||||
Parameters:
|
||||
posts: Captured ``(path, payload)`` pairs.
|
||||
|
||||
Returns:
|
||||
The positions payloads.
|
||||
"""
|
||||
return [payload for path, payload in posts if path == "/api/positions"]
|
||||
|
||||
|
||||
def test_the_host_is_positioned_at_connect_and_every_hour(mesh):
|
||||
"""One row at connect and one per report; the peer is never positioned.
|
||||
|
||||
Against the tree before this feature it fails with ``assert [] == [...]``:
|
||||
no positions row is posted and no host record carries a position.
|
||||
"""
|
||||
state = make_state(provider=ReticulumProvider(), inactivity_reconnect_secs=3600.0)
|
||||
connected_at = _WALL_OFFSET + int(mesh.clock["mono"])
|
||||
_busy_pass(state, mesh) # connect: snapshot, then the first report
|
||||
assert [row["position_time"] for row in _rows(mesh.posts)] == [connected_at]
|
||||
for _ in range(61): # one report interval, plus one loop
|
||||
_busy_pass(state, mesh)
|
||||
|
||||
rows = _rows(mesh.posts)
|
||||
assert [row["position_time"] for row in rows] == [connected_at, connected_at + 3600]
|
||||
assert {(row["node_id"], row["protocol"], row["latitude"]) for row in rows} == {
|
||||
(_HOST, "reticulum", 52.5029)
|
||||
}
|
||||
host = _records(mesh.posts, _HOST)
|
||||
assert len(host) == 6 # two aspects: snapshot, connect report, hourly report
|
||||
assert all(node["position"]["longitude"] == 13.4042 for node in host)
|
||||
peer = _records(mesh.posts, _PEER)
|
||||
assert peer and all("position" not in node for node in peer)
|
||||
assert mesh.sent == [] # local reads only (SPEC RN5, MA7)
|
||||
|
||||
|
||||
def test_a_host_with_nothing_announcing_is_positioned_hourly(mesh, monkeypatch):
|
||||
"""Docker: the bare record latches the snapshot, so the report runs hourly."""
|
||||
mesh.stack.local.clear() # nothing announces on the default volume
|
||||
monkeypatch.setattr(config, "INGESTOR_NODE_ID", _HOST)
|
||||
state = make_state(provider=ReticulumProvider(), inactivity_reconnect_secs=3600.0)
|
||||
_busy_pass(state, mesh)
|
||||
|
||||
assert state.initial_snapshot_sent # before this feature: nothing to latch on
|
||||
host = _records(mesh.posts, _HOST)
|
||||
assert len(host) == 2 # the snapshot and the connect report
|
||||
for node in host:
|
||||
assert node["user"] == {
|
||||
"shortName": "2771",
|
||||
"longName": "Reticulum 2771",
|
||||
"role": "PEER",
|
||||
}
|
||||
assert "destination" not in node
|
||||
assert node["position"]["latitude"] == 52.5029
|
||||
assert len(_rows(mesh.posts)) == 1
|
||||
for _ in range(61):
|
||||
_busy_pass(state, mesh)
|
||||
assert len(_rows(mesh.posts)) == 2
|
||||
|
||||
|
||||
def test_no_secret_from_the_rns_config_reaches_a_post(mesh):
|
||||
"""``rpc_key`` and an interface ``passphrase`` never leave the parser (RP1)."""
|
||||
state = make_state(provider=ReticulumProvider(), inactivity_reconnect_secs=3600.0)
|
||||
_busy_pass(state, mesh)
|
||||
flat = json.dumps(mesh.posts)
|
||||
assert _rows(mesh.posts) # the position was published...
|
||||
assert _SECRET not in flat and _PASSPHRASE not in flat # ...and nothing else
|
||||
|
||||
|
||||
def test_without_the_keys_the_loop_posts_no_position(mesh, tmp_path):
|
||||
"""A config with no position keys behaves exactly as before (SPEC RP2)."""
|
||||
(tmp_path / "config").write_text(
|
||||
"[interfaces]\n [[RNode]]\n type = RNodeInterface\n", encoding="utf-8"
|
||||
)
|
||||
state = make_state(provider=ReticulumProvider(), inactivity_reconnect_secs=3600.0)
|
||||
for _ in range(3):
|
||||
_busy_pass(state, mesh)
|
||||
assert _rows(mesh.posts) == []
|
||||
assert all("position" not in node for node in _records(mesh.posts, _HOST))
|
||||
assert _records(mesh.posts, _HOST) # the host is still reported (SPEC RE8)
|
||||
|
||||
|
||||
def test_the_bare_record_is_wired_into_the_provider():
|
||||
"""The provider's own builder, as the integration tests above exercise it."""
|
||||
record = _mod._bare_host_record(_HOST, 7)
|
||||
assert record == {
|
||||
"nodeId": _HOST,
|
||||
"lastHeard": 7,
|
||||
"protocol": "reticulum",
|
||||
"user": {"shortName": "2771", "longName": "Reticulum 2771", "role": "PEER"},
|
||||
}
|
||||
Reference in new issue
Block a user