mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
396 lines
14 KiB
Python
396 lines
14 KiB
Python
# 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.
|
|
"""A Meshtastic NodeInfo is filed under the node that sent it (SPEC NI1).
|
|
|
|
Packets are real ``MeshPacket`` protobufs handed to the pinned meshtastic
|
|
library's own ``MeshInterface._handlePacketFromRadio`` (``noProto=True``: no
|
|
device, no serial port, pubsub delivered synchronously) and from there to the
|
|
production subscription, so every case sees the dict shapes a radio produces.
|
|
The radio's nodeDB at connect holds three nodes: the sender ``A``, the node
|
|
``B`` its NodeInfo names, and a third node ``C``. Only the HTTP queue is
|
|
replaced; nothing touches the network.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
import meshtastic # noqa: E402
|
|
from meshtastic import mesh_interface as lib_mesh_interface # noqa: E402
|
|
from meshtastic.protobuf import mesh_pb2, portnums_pb2 # noqa: E402
|
|
from pubsub import pub # noqa: E402
|
|
|
|
from daemon_fakes import make_state # noqa: E402
|
|
|
|
import data.mesh_ingestor.activity as activity # noqa: E402
|
|
import data.mesh_ingestor.channels as channels # noqa: E402
|
|
import data.mesh_ingestor.config as config # noqa: E402
|
|
import data.mesh_ingestor.daemon as daemon # noqa: E402
|
|
import data.mesh_ingestor.handlers as handlers # noqa: E402
|
|
import data.mesh_ingestor.handlers._state as _state # noqa: E402
|
|
import data.mesh_ingestor.handlers.ignored as ignored_mod # noqa: E402
|
|
import data.mesh_ingestor.interfaces as interfaces # noqa: E402
|
|
import data.mesh_ingestor.queue as queue # noqa: E402
|
|
from data.mesh_ingestor.protocols.meshtastic import MeshtasticProvider # noqa: E402
|
|
|
|
SENDER = 0xA1A1A1A1
|
|
"""Node number in the header ``from`` of every packet: the radio that sent it."""
|
|
|
|
SENDER_ID = "!a1a1a1a1"
|
|
"""Canonical id of :data:`SENDER`."""
|
|
|
|
NAMED = 0xB2B2B2B2
|
|
"""Node number of the node the sender's NodeInfo names as itself."""
|
|
|
|
NAMED_ID = "!b2b2b2b2"
|
|
"""Canonical id of :data:`NAMED`."""
|
|
|
|
OTHER = 0xC3C3C3C3
|
|
"""A third node in the radio's nodeDB."""
|
|
|
|
OTHER_ID = "!c3c3c3c3"
|
|
"""Canonical id of :data:`OTHER`."""
|
|
|
|
NODEINFO_APP = portnums_pb2.PortNum.NODEINFO_APP
|
|
"""Port number of the dispatch-table entry the ingestor guards."""
|
|
|
|
|
|
@pytest.fixture
|
|
def posts(monkeypatch, tmp_path):
|
|
"""Neutral ingest filters; every queued POST is recorded instead of sent.
|
|
|
|
Returns:
|
|
A list that collects ``(path, payload)`` for each queued request.
|
|
"""
|
|
|
|
for name, value in (
|
|
("DEBUG", False),
|
|
("PROTOCOL", "meshtastic"),
|
|
("ALLOWED_CHANNELS", ()),
|
|
("HIDDEN_CHANNELS", ()),
|
|
("PRIMARY_CHANNEL_ONLY", False),
|
|
("DROP_VIA_MQTT", False),
|
|
("PRIMARY_CHANNEL_NAME", ""),
|
|
("LORA_FREQ", None),
|
|
("MODEM_PRESET", None),
|
|
):
|
|
monkeypatch.setattr(config, name, value, raising=False)
|
|
for name in (
|
|
"_host_node_id",
|
|
"_host_telemetry_last_rx",
|
|
"_host_nodeinfo_last_seen",
|
|
):
|
|
monkeypatch.setattr(_state, name, None)
|
|
monkeypatch.setattr(ignored_mod, "_IGNORED_PACKET_LOG_PATH", tmp_path / "x.txt")
|
|
monkeypatch.setattr(activity, "_packet_count", 0)
|
|
channels._reset_channel_cache()
|
|
recorded: list[tuple[str, dict]] = []
|
|
monkeypatch.setattr(
|
|
queue,
|
|
"_queue_post_json",
|
|
lambda path, payload, *, priority=None, **_kw: recorded.append((path, payload)),
|
|
)
|
|
yield recorded
|
|
channels._reset_channel_cache()
|
|
|
|
|
|
@pytest.fixture
|
|
def warnings_logged(monkeypatch):
|
|
"""Capture ``warn`` log lines instead of printing them.
|
|
|
|
Returns:
|
|
A list that collects each warning's context and metadata.
|
|
"""
|
|
|
|
logged: list[dict] = []
|
|
|
|
def record(message, *, context=None, severity="debug", **metadata):
|
|
"""Keep the warnings and drop every other log line."""
|
|
|
|
if severity == "warn":
|
|
logged.append({"context": context, **metadata})
|
|
|
|
monkeypatch.setattr(config, "_debug_log", record)
|
|
return logged
|
|
|
|
|
|
@pytest.fixture
|
|
def radio(posts, monkeypatch):
|
|
"""The pinned library's interface after connect, wired to the subscription.
|
|
|
|
The ingestor's meshtastic patches are applied to the real library the way
|
|
importing :mod:`data.mesh_ingestor.interfaces` applies them; the library's
|
|
dispatch table and module attribute are restored afterwards.
|
|
|
|
Returns:
|
|
A namespace with the interface ``iface`` and the recorded ``posts``.
|
|
"""
|
|
|
|
monkeypatch.setitem(
|
|
meshtastic.protocols, NODEINFO_APP, meshtastic.protocols[NODEINFO_APP]
|
|
)
|
|
monkeypatch.setattr(meshtastic, "_onNodeInfoReceive", meshtastic._onNodeInfoReceive)
|
|
interfaces._patch_meshtastic_nodeinfo_handler()
|
|
monkeypatch.setattr(lib_mesh_interface.publishingThread, "queueWork", lambda w: w())
|
|
iface = lib_mesh_interface.MeshInterface(noProto=True)
|
|
iface.nodes = {}
|
|
iface.nodesByNum = {}
|
|
topics = MeshtasticProvider().subscribe()
|
|
for num, name in (
|
|
(SENDER, "Sender Radio"),
|
|
(NAMED, "Named Node"),
|
|
(OTHER, "Other"),
|
|
):
|
|
info = mesh_pb2.NodeInfo(
|
|
num=num, user=mesh_pb2.User(id=f"!{num:08x}", long_name=name)
|
|
)
|
|
iface._handleFromRadio(mesh_pb2.FromRadio(node_info=info).SerializeToString())
|
|
yield SimpleNamespace(iface=iface, posts=posts)
|
|
for topic in topics:
|
|
pub.unsubscribe(handlers.on_receive, topic)
|
|
|
|
|
|
def _hear(iface, port: str, payload: bytes, *, packet_id: int) -> None:
|
|
"""Deliver one received ``MeshPacket`` whose header ``from`` is :data:`SENDER`."""
|
|
|
|
packet = mesh_pb2.MeshPacket()
|
|
setattr(packet, "from", SENDER)
|
|
packet.to = 0xFFFFFFFF
|
|
packet.id = packet_id
|
|
packet.channel = 0
|
|
packet.rx_time = int(time.time())
|
|
packet.decoded.portnum = portnums_pb2.PortNum.Value(port)
|
|
packet.decoded.payload = payload
|
|
iface._handlePacketFromRadio(packet)
|
|
|
|
|
|
def _profile_naming_another_node() -> bytes:
|
|
"""A ``NODEINFO_APP`` payload from :data:`SENDER` whose ``user.id`` is :data:`NAMED_ID`."""
|
|
|
|
return mesh_pb2.User(
|
|
id=NAMED_ID, long_name="Mallory", short_name="MLRY", hw_model=9
|
|
).SerializeToString()
|
|
|
|
|
|
def _varint(value: int) -> bytes:
|
|
"""Encode ``value`` as a protobuf base-128 varint."""
|
|
|
|
out = bytearray()
|
|
while True:
|
|
byte = value & 0x7F
|
|
value >>= 7
|
|
if not value:
|
|
out.append(byte)
|
|
return bytes(out)
|
|
out.append(byte | 0x80)
|
|
|
|
|
|
def _nodeinfo_payload_with_another_number() -> bytes:
|
|
"""A ``NODEINFO_APP`` payload from :data:`SENDER` that carries :data:`NAMED`'s number.
|
|
|
|
Field 1 appears twice: as a varint it is ``NodeInfo.num`` (the number of
|
|
:data:`NAMED`), as a string ``User.id`` (the sender's own id). The
|
|
library parses the payload as a ``User`` and sees the sender's id; the
|
|
ingestor's decoder parses it as a ``NodeInfo`` and sees the other number.
|
|
"""
|
|
|
|
sender_id = SENDER_ID.encode()
|
|
profile = mesh_pb2.User(
|
|
id=SENDER_ID, long_name="Sender Radio", short_name="SNDR"
|
|
).SerializeToString()
|
|
return (
|
|
b"\x08"
|
|
+ _varint(NAMED)
|
|
+ b"\x0a"
|
|
+ bytes([len(sender_id)])
|
|
+ sender_id
|
|
+ b"\x12"
|
|
+ bytes([len(profile)])
|
|
+ profile
|
|
)
|
|
|
|
|
|
def _node_posts(recorded) -> dict[str, dict]:
|
|
"""Merge every ``/api/nodes`` POST body into ``{node_id: entry}``."""
|
|
|
|
out: dict[str, dict] = {}
|
|
for path, payload in recorded:
|
|
if path == "/api/nodes":
|
|
out.update({k: v for k, v in payload.items() if k.startswith("!")})
|
|
return out
|
|
|
|
|
|
def _snapshot(radio) -> dict[str, dict]:
|
|
"""Run the daemon's node-list snapshot over ``radio``; return the posted nodes."""
|
|
|
|
radio.posts.clear()
|
|
state = make_state(provider=MeshtasticProvider(), iface=radio.iface)
|
|
assert daemon._try_send_snapshot(state)
|
|
return _node_posts(radio.posts)
|
|
|
|
|
|
def test_nodeinfo_naming_another_node_posts_nothing_keyed_on_it(radio, warnings_logged):
|
|
"""A NodeInfo from A whose ``user.id`` is B is dropped with a warning naming both."""
|
|
|
|
_hear(radio.iface, "NODEINFO_APP", _profile_naming_another_node(), packet_id=0x51)
|
|
|
|
assert _node_posts(radio.posts) == {}
|
|
assert warnings_logged == [
|
|
{
|
|
"context": "handlers.store_nodeinfo",
|
|
"from_id": SENDER_ID,
|
|
"user_id": NAMED_ID,
|
|
}
|
|
]
|
|
# The library's nodeDB keeps each node under its own profile.
|
|
assert radio.iface.nodesByNum[SENDER]["user"]["id"] == SENDER_ID
|
|
assert radio.iface.nodes[NAMED_ID]["num"] == NAMED
|
|
|
|
|
|
def test_later_packets_stay_attributed_to_the_header_source(radio):
|
|
"""After such a NodeInfo, A's own position and text still post under A."""
|
|
|
|
_hear(radio.iface, "NODEINFO_APP", _profile_naming_another_node(), packet_id=0x52)
|
|
radio.posts.clear()
|
|
position = mesh_pb2.Position(
|
|
latitude_i=481_000_000, longitude_i=116_000_000, time=int(time.time())
|
|
).SerializeToString()
|
|
_hear(radio.iface, "POSITION_APP", position, packet_id=0x53)
|
|
_hear(radio.iface, "TEXT_MESSAGE_APP", b"hello from the sender", packet_id=0x54)
|
|
|
|
attributed = [
|
|
(path, payload.get("node_id") or payload.get("from_id"))
|
|
for path, payload in radio.posts
|
|
if path in ("/api/positions", "/api/messages")
|
|
]
|
|
assert attributed == [("/api/positions", SENDER_ID), ("/api/messages", SENDER_ID)]
|
|
|
|
|
|
def test_neighborinfo_naming_another_node_is_dropped(radio, warnings_logged):
|
|
"""A NeighborInfo from A whose ``node_id`` is B posts nothing; A's own posts under A."""
|
|
|
|
foreign = mesh_pb2.NeighborInfo(
|
|
node_id=NAMED, neighbors=[mesh_pb2.Neighbor(node_id=OTHER, snr=5.0)]
|
|
).SerializeToString()
|
|
own = mesh_pb2.NeighborInfo(
|
|
node_id=SENDER, neighbors=[mesh_pb2.Neighbor(node_id=OTHER, snr=5.0)]
|
|
).SerializeToString()
|
|
|
|
_hear(radio.iface, "NEIGHBORINFO_APP", foreign, packet_id=0x61)
|
|
_hear(radio.iface, "NEIGHBORINFO_APP", own, packet_id=0x62)
|
|
|
|
filed = [
|
|
(body["node_id"], body["node_num"])
|
|
for path, body in radio.posts
|
|
if path == "/api/neighbors"
|
|
]
|
|
assert filed == [(SENDER_ID, SENDER)]
|
|
assert warnings_logged == [
|
|
{
|
|
"context": "handlers.store_neighborinfo",
|
|
"from_id": SENDER_ID,
|
|
"node_id": NAMED_ID,
|
|
}
|
|
]
|
|
|
|
|
|
def test_nodeinfo_number_naming_another_node_is_not_posted(radio, warnings_logged):
|
|
"""A NodeInfo-format payload carrying B's number posts the sender's own number."""
|
|
|
|
_hear(
|
|
radio.iface,
|
|
"NODEINFO_APP",
|
|
_nodeinfo_payload_with_another_number(),
|
|
packet_id=0x64,
|
|
)
|
|
|
|
nodes = _node_posts(radio.posts)
|
|
assert {node_id: entry["num"] for node_id, entry in nodes.items()} == {
|
|
SENDER_ID: SENDER
|
|
}
|
|
assert warnings_logged == []
|
|
|
|
|
|
def test_nodeinfo_without_a_user_id_reaches_the_handler(radio):
|
|
"""A NodeInfo whose ``User`` has no ``id`` is filed under its sender.
|
|
|
|
The library's own NodeInfo callback raised ``KeyError('id')`` on it, which
|
|
ended ``_handlePacketFromRadio`` before the packet was published.
|
|
"""
|
|
|
|
profile = mesh_pb2.User(long_name="No Id", short_name="NOID").SerializeToString()
|
|
|
|
_hear(radio.iface, "NODEINFO_APP", profile, packet_id=0x63)
|
|
|
|
nodes = _node_posts(radio.posts)
|
|
assert {node_id: entry["user"]["longName"] for node_id, entry in nodes.items()} == {
|
|
SENDER_ID: "No Id"
|
|
}
|
|
assert radio.iface.nodesByNum[SENDER]["user"]["id"] == SENDER_ID
|
|
|
|
|
|
def test_nodeinfo_with_a_non_canonical_user_id_is_dropped(radio, warnings_logged):
|
|
"""Only the sender's canonical id files a NodeInfo under the sender.
|
|
|
|
A ``user.id`` that names no node, or the sender in another spelling, is a
|
|
present id other than the sender's canonical one: dropped like one naming
|
|
another node, and kept out of the library's node database.
|
|
"""
|
|
|
|
for packet_id, claimed in ((0x65, "!Decrypted"), (0x66, "!A1A1A1A1")):
|
|
profile = mesh_pb2.User(id=claimed, long_name="Decoded").SerializeToString()
|
|
_hear(radio.iface, "NODEINFO_APP", profile, packet_id=packet_id)
|
|
|
|
assert _node_posts(radio.posts) == {}
|
|
assert warnings_logged == [
|
|
{"context": "handlers.store_nodeinfo", "from_id": SENDER_ID, "user_id": claimed}
|
|
for claimed in ("!Decrypted", "!A1A1A1A1")
|
|
]
|
|
assert radio.iface.nodesByNum[SENDER]["user"]["id"] == SENDER_ID
|
|
assert set(radio.iface.nodes) == {SENDER_ID, NAMED_ID, OTHER_ID}
|
|
|
|
|
|
def test_snapshot_skips_an_entry_whose_num_disagrees_with_its_id(radio):
|
|
"""A nodeDB entry filed under another node's id is not posted under that id."""
|
|
|
|
# The library view an unguarded NodeInfo naming B leaves behind: A's own
|
|
# entry is filed under B's id as well.
|
|
radio.iface.nodes[NAMED_ID] = radio.iface.nodesByNum[SENDER]
|
|
|
|
nodes = _snapshot(radio)
|
|
|
|
assert {node_id: entry["num"] for node_id, entry in nodes.items()} == {
|
|
SENDER_ID: SENDER,
|
|
OTHER_ID: OTHER,
|
|
}
|
|
|
|
|
|
def test_snapshot_still_posts_every_radio_entry(radio):
|
|
"""The snapshot legitimately posts other nodes: every entry under its own id."""
|
|
|
|
nodes = _snapshot(radio)
|
|
|
|
assert set(nodes) == {SENDER_ID, NAMED_ID, OTHER_ID}
|
|
for node_id, entry in nodes.items():
|
|
assert f"!{entry['num']:08x}" == node_id == entry["user"]["id"]
|