mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-06-30 06:51:23 +02:00
3cfa0db7e6
* web: distinguish meshcore from meshtastic in frontend * fix mark_packet_seen bug * web: distinguish meshcore from meshtastic in frontend * address review comments * address review comments * address review comments
1668 lines
57 KiB
Python
1668 lines
57 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.
|
|
"""Unit tests for :mod:`data.mesh_ingestor.provider` integration seams."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from data.mesh_ingestor import daemon # noqa: E402 - path setup
|
|
from data.mesh_ingestor.provider import Provider # noqa: E402 - path setup
|
|
from data.mesh_ingestor.providers.meshtastic import ( # noqa: E402 - path setup
|
|
MeshtasticProvider,
|
|
)
|
|
from data.mesh_ingestor.connection import parse_tcp_target # noqa: E402 - path setup
|
|
from data.mesh_ingestor.providers.meshcore import ( # noqa: E402 - path setup
|
|
MeshcoreProvider,
|
|
_MeshcoreInterface,
|
|
_contact_to_node_dict,
|
|
_derive_message_id,
|
|
_make_connection,
|
|
_make_event_handlers,
|
|
_meshcore_adv_type_to_role,
|
|
_meshcore_node_id,
|
|
_meshcore_short_name,
|
|
_process_contact_update,
|
|
_process_contacts,
|
|
_process_self_info,
|
|
_pubkey_prefix_to_node_id,
|
|
_record_meshcore_message,
|
|
_self_info_to_node_dict,
|
|
_to_json_safe,
|
|
)
|
|
|
|
|
|
def test_meshtastic_provider_satisfies_protocol():
|
|
"""MeshtasticProvider must structurally satisfy the Provider Protocol."""
|
|
assert isinstance(MeshtasticProvider(), Provider)
|
|
|
|
|
|
def test_daemon_main_uses_provider_connect(monkeypatch):
|
|
calls = {"connect": 0}
|
|
|
|
class FakeProvider(MeshtasticProvider):
|
|
def subscribe(self):
|
|
return []
|
|
|
|
def connect(self, *, active_candidate): # type: ignore[override]
|
|
calls["connect"] += 1
|
|
|
|
# Return a minimal iface and stop immediately via Event.
|
|
class Iface:
|
|
nodes = {}
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
return Iface(), "serial0", active_candidate
|
|
|
|
def extract_host_node_id(self, iface): # type: ignore[override]
|
|
return "!host"
|
|
|
|
def node_snapshot_items(self, iface): # type: ignore[override]
|
|
return []
|
|
|
|
# Make the loop exit quickly.
|
|
class AutoStopEvent:
|
|
def __init__(self):
|
|
self._set = False
|
|
|
|
def set(self):
|
|
self._set = True
|
|
|
|
def is_set(self):
|
|
return self._set
|
|
|
|
def wait(self, _timeout=None):
|
|
self._set = True
|
|
return True
|
|
|
|
monkeypatch.setattr(daemon.config, "SNAPSHOT_SECS", 0)
|
|
monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0)
|
|
monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0)
|
|
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
|
|
monkeypatch.setattr(daemon.config, "_INGESTOR_HEARTBEAT_SECS", 0)
|
|
monkeypatch.setattr(daemon.config, "ENERGY_SAVING", False)
|
|
monkeypatch.setattr(daemon.config, "_INACTIVITY_RECONNECT_SECS", 0)
|
|
monkeypatch.setattr(daemon.config, "CONNECTION", "serial0")
|
|
|
|
monkeypatch.setattr(
|
|
daemon,
|
|
"threading",
|
|
types.SimpleNamespace(
|
|
Event=AutoStopEvent,
|
|
current_thread=daemon.threading.current_thread,
|
|
main_thread=daemon.threading.main_thread,
|
|
),
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
daemon.handlers, "register_host_node_id", lambda *_a, **_k: None
|
|
)
|
|
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
|
|
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
|
|
monkeypatch.setattr(
|
|
daemon.ingestors, "set_ingestor_node_id", lambda *_a, **_k: None
|
|
)
|
|
monkeypatch.setattr(
|
|
daemon.ingestors, "queue_ingestor_heartbeat", lambda *_a, **_k: True
|
|
)
|
|
|
|
daemon.main(provider=FakeProvider())
|
|
assert calls["connect"] >= 1
|
|
|
|
|
|
def test_node_snapshot_items_retries_on_concurrent_mutation(monkeypatch):
|
|
"""node_snapshot_items must retry on dict-mutation RuntimeError, not raise."""
|
|
from data.mesh_ingestor.providers.meshtastic import MeshtasticProvider
|
|
|
|
attempt = {"n": 0}
|
|
|
|
class MutatingNodes:
|
|
def items(self):
|
|
attempt["n"] += 1
|
|
if attempt["n"] < 3:
|
|
raise RuntimeError("dictionary changed size during iteration")
|
|
return [("!aabbccdd", {"num": 1})]
|
|
|
|
class FakeIface:
|
|
nodes = MutatingNodes()
|
|
|
|
monkeypatch.setattr("time.sleep", lambda _: None)
|
|
result = MeshtasticProvider().node_snapshot_items(FakeIface())
|
|
assert result == [("!aabbccdd", {"num": 1})]
|
|
assert attempt["n"] == 3
|
|
|
|
|
|
def test_node_snapshot_items_returns_empty_after_retry_exhaustion(monkeypatch):
|
|
"""node_snapshot_items returns [] (non-fatal) when all retries fail."""
|
|
from data.mesh_ingestor.providers.meshtastic import MeshtasticProvider
|
|
import data.mesh_ingestor.providers.meshtastic as _mod
|
|
|
|
class AlwaysMutating:
|
|
def items(self):
|
|
raise RuntimeError("dictionary changed size during iteration")
|
|
|
|
class FakeIface:
|
|
nodes = AlwaysMutating()
|
|
|
|
monkeypatch.setattr("time.sleep", lambda _: None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
result = MeshtasticProvider().node_snapshot_items(FakeIface())
|
|
assert result == []
|
|
|
|
|
|
def test_meshtastic_subscribe_is_idempotent(monkeypatch):
|
|
"""Calling subscribe() twice returns the cached list without re-subscribing."""
|
|
import data.mesh_ingestor.providers.meshtastic as _m
|
|
|
|
subscribe_calls: list[str] = []
|
|
|
|
monkeypatch.setattr(
|
|
_m,
|
|
"pub",
|
|
types.SimpleNamespace(
|
|
subscribe=lambda _h, topic: subscribe_calls.append(topic)
|
|
),
|
|
)
|
|
|
|
provider = MeshtasticProvider()
|
|
first = provider.subscribe()
|
|
second = provider.subscribe()
|
|
|
|
assert first == second
|
|
# pub.subscribe should only have been called once (first invocation)
|
|
assert len(subscribe_calls) == len(first)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MeshcoreProvider tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_meshcore_provider_satisfies_protocol():
|
|
"""MeshcoreProvider must structurally satisfy the Provider Protocol."""
|
|
assert isinstance(MeshcoreProvider(), Provider)
|
|
|
|
|
|
def test_meshcore_provider_name():
|
|
"""MeshcoreProvider.name must be 'meshcore'."""
|
|
assert MeshcoreProvider().name == "meshcore"
|
|
|
|
|
|
def test_meshcore_subscribe_returns_empty_list():
|
|
"""MeshCore has no pubsub topics; subscribe() must return an empty list."""
|
|
assert MeshcoreProvider().subscribe() == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
[
|
|
"meshnode.local:4403",
|
|
"meshtastic.local:4403",
|
|
"hostname:1234",
|
|
"otherhost:80",
|
|
],
|
|
)
|
|
def test_meshcore_connect_accepts_tcp_targets(target, monkeypatch):
|
|
"""connect() must succeed for TCP host:port targets."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
iface, resolved, next_candidate = MeshcoreProvider().connect(
|
|
active_candidate=target
|
|
)
|
|
assert iface is not None
|
|
assert resolved == target
|
|
assert next_candidate == target
|
|
iface.close()
|
|
|
|
|
|
def _fake_run_meshcore(*, error=None, host_node_id=None):
|
|
"""Return a fake ``_run_meshcore`` coroutine for use in connect() tests.
|
|
|
|
The coroutine immediately signals success (or the given error) without
|
|
opening a real serial port, then waits for the stop event so that
|
|
``_MeshcoreInterface.close()`` works correctly.
|
|
"""
|
|
import asyncio as _asyncio
|
|
|
|
async def _fake(iface, target, connected_event, error_holder):
|
|
stop = _asyncio.Event()
|
|
iface._stop_event = stop
|
|
if error is not None:
|
|
error_holder[0] = error
|
|
else:
|
|
iface.isConnected = True
|
|
if host_node_id is not None:
|
|
iface.host_node_id = host_node_id
|
|
connected_event.set()
|
|
await stop.wait()
|
|
|
|
return _fake
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
[
|
|
"/dev/ttyUSB0",
|
|
"/dev/ttyACM0",
|
|
"COM3",
|
|
"AA:BB:CC:DD:EE:FF",
|
|
"12345678-1234-1234-1234-123456789abc",
|
|
],
|
|
)
|
|
def test_meshcore_connect_accepts_serial_ble_targets(target, monkeypatch):
|
|
"""connect() must succeed for explicit serial ports and BLE addresses."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
iface, resolved, next_candidate = MeshcoreProvider().connect(
|
|
active_candidate=target
|
|
)
|
|
assert iface is not None
|
|
assert resolved == target
|
|
assert next_candidate == target
|
|
iface.close()
|
|
|
|
|
|
def test_meshcore_connect_auto_discovers_serial(monkeypatch):
|
|
"""connect() with no target must resolve to the first serial candidate."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(
|
|
_mod, "default_serial_targets", lambda: ["/dev/ttyACM0", "/dev/ttyUSB0"]
|
|
)
|
|
iface, resolved, next_candidate = MeshcoreProvider().connect(active_candidate=None)
|
|
assert iface is not None
|
|
assert resolved == "/dev/ttyACM0"
|
|
assert next_candidate == "/dev/ttyACM0"
|
|
iface.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target,expected_class_name",
|
|
[
|
|
# Serial paths
|
|
("/dev/ttyUSB0", "SerialConnection"),
|
|
("/dev/ttyACM0", "SerialConnection"),
|
|
("COM3", "SerialConnection"),
|
|
# BLE targets
|
|
("AA:BB:CC:DD:EE:FF", "BLEConnection"),
|
|
("12345678-1234-1234-1234-123456789abc", "BLEConnection"),
|
|
# TCP targets
|
|
("hostname:4403", "TCPConnection"),
|
|
("192.168.1.1:4403", "TCPConnection"),
|
|
("meshcore-node.local:4403", "TCPConnection"),
|
|
],
|
|
)
|
|
def test_make_connection_routes_to_correct_class(
|
|
target, expected_class_name, monkeypatch
|
|
):
|
|
"""_make_connection must instantiate the correct meshcore connection class."""
|
|
import types
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
instances: list = []
|
|
|
|
def _make_mock(name):
|
|
def _cls(*args, **kwargs):
|
|
obj = types.SimpleNamespace(name=name, args=args, kwargs=kwargs)
|
|
instances.append(obj)
|
|
return obj
|
|
|
|
_cls.__name__ = name
|
|
return _cls
|
|
|
|
fake_meshcore = types.ModuleType("meshcore")
|
|
fake_meshcore.BLEConnection = _make_mock("BLEConnection")
|
|
fake_meshcore.SerialConnection = _make_mock("SerialConnection")
|
|
fake_meshcore.TCPConnection = _make_mock("TCPConnection")
|
|
|
|
import sys as _sys
|
|
|
|
original = _sys.modules.get("meshcore")
|
|
try:
|
|
_sys.modules["meshcore"] = fake_meshcore
|
|
result = _make_connection(target, 115200)
|
|
finally:
|
|
if original is None:
|
|
_sys.modules.pop("meshcore", None)
|
|
else:
|
|
_sys.modules["meshcore"] = original
|
|
|
|
assert len(instances) == 1
|
|
assert instances[0].name == expected_class_name
|
|
|
|
|
|
def test_meshcore_connect_returns_closeable_interface(monkeypatch):
|
|
"""The interface returned by connect() must expose a close() method."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
iface, _, _ = MeshcoreProvider().connect(active_candidate="/dev/ttyUSB0")
|
|
assert callable(getattr(iface, "close", None))
|
|
iface.close() # must not raise
|
|
|
|
|
|
def test_meshcore_extract_host_node_id_none_by_default(monkeypatch):
|
|
"""extract_host_node_id returns None when the interface has no host_node_id."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
iface, _, _ = MeshcoreProvider().connect(active_candidate="/dev/ttyUSB0")
|
|
assert MeshcoreProvider().extract_host_node_id(iface) is None
|
|
iface.close()
|
|
|
|
|
|
def test_meshcore_extract_host_node_id_set_on_connect(monkeypatch):
|
|
"""extract_host_node_id returns the node ID set by the connection handler."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(
|
|
_mod, "_run_meshcore", _fake_run_meshcore(host_node_id="!aabbccdd")
|
|
)
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
iface, _, _ = MeshcoreProvider().connect(active_candidate="/dev/ttyUSB0")
|
|
assert MeshcoreProvider().extract_host_node_id(iface) == "!aabbccdd"
|
|
iface.close()
|
|
|
|
|
|
def test_meshcore_connect_propagates_connection_error(monkeypatch):
|
|
"""connect() must re-raise a ConnectionError when the handshake fails."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
exc = ConnectionError("no response")
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore(error=exc))
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
with pytest.raises(ConnectionError, match="no response"):
|
|
MeshcoreProvider().connect(active_candidate="/dev/ttyUSB0")
|
|
|
|
|
|
def test_meshcore_node_snapshot_items_non_interface():
|
|
"""node_snapshot_items must return [] for any non-_MeshcoreInterface object."""
|
|
assert MeshcoreProvider().node_snapshot_items(object()) == []
|
|
|
|
|
|
def test_meshcore_node_snapshot_items_with_contacts(monkeypatch):
|
|
"""node_snapshot_items returns contacts converted to node dicts."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
iface, _, _ = MeshcoreProvider().connect(active_candidate="/dev/ttyUSB0")
|
|
|
|
pub_key = "aabbccdd" + "00" * 28
|
|
iface._update_contact(
|
|
{"public_key": pub_key, "adv_name": "Alice", "last_advert": 1000}
|
|
)
|
|
|
|
items = MeshcoreProvider().node_snapshot_items(iface)
|
|
assert len(items) == 1
|
|
node_id, node_dict = items[0]
|
|
assert node_id == "!aabbccdd"
|
|
assert node_dict["user"]["longName"] == "Alice"
|
|
assert node_dict["user"]["shortName"] == "aabb"
|
|
iface.close()
|
|
|
|
|
|
def test_parse_tcp_target_detects_host_port():
|
|
"""parse_tcp_target must return (host, port) for host:port strings."""
|
|
assert parse_tcp_target("meshnode.local:4403") == ("meshnode.local", 4403)
|
|
assert parse_tcp_target("meshtastic.local:4403") == ("meshtastic.local", 4403)
|
|
|
|
|
|
def test_parse_tcp_target_rejects_serial_ble():
|
|
"""parse_tcp_target must return None for serial paths and BLE addresses."""
|
|
assert parse_tcp_target("/dev/ttyUSB0") is None
|
|
assert parse_tcp_target("AA:BB:CC:DD:EE:FF") is None
|
|
assert parse_tcp_target("COM3") is None
|
|
# BLE MAC address whose final octet is all-decimal must not be a false positive.
|
|
assert parse_tcp_target("AA:BB:CC:DD:EE:12") is None
|
|
|
|
|
|
def test_record_meshcore_message_skipped_without_debug(monkeypatch, tmp_path):
|
|
"""_record_meshcore_message must not write anything when DEBUG is False."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "DEBUG", False)
|
|
log_path = tmp_path / "ignored-meshcore.txt"
|
|
monkeypatch.setattr(_mod, "_IGNORED_MESSAGE_LOG_PATH", log_path)
|
|
|
|
_record_meshcore_message({"key": "value"}, source="/dev/ttyUSB0")
|
|
|
|
assert not log_path.exists()
|
|
|
|
|
|
def test_record_meshcore_message_writes_with_debug(monkeypatch, tmp_path):
|
|
"""_record_meshcore_message must append a JSON line when DEBUG=1."""
|
|
import json as _json
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "DEBUG", True)
|
|
log_path = tmp_path / "ignored-meshcore.txt"
|
|
monkeypatch.setattr(_mod, "_IGNORED_MESSAGE_LOG_PATH", log_path)
|
|
|
|
_record_meshcore_message({"hello": "world"}, source="/dev/ttyUSB0")
|
|
|
|
assert log_path.exists()
|
|
line = log_path.read_text(encoding="utf-8").strip()
|
|
entry = _json.loads(line)
|
|
assert entry["source"] == "/dev/ttyUSB0"
|
|
assert "timestamp" in entry
|
|
assert "message" in entry
|
|
|
|
|
|
def test_record_meshcore_message_serialises_bytes(monkeypatch, tmp_path):
|
|
"""bytes values in the message must be base64-encoded, not repr'd."""
|
|
import json as _json
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "DEBUG", True)
|
|
log_path = tmp_path / "ignored-meshcore.txt"
|
|
monkeypatch.setattr(_mod, "_IGNORED_MESSAGE_LOG_PATH", log_path)
|
|
|
|
_record_meshcore_message({"payload": b"\xde\xad\xbe\xef"}, source="ble")
|
|
|
|
entry = _json.loads(log_path.read_text(encoding="utf-8").strip())
|
|
# The dict should be preserved and bytes base64-encoded, not str()'d.
|
|
assert entry["message"] == {"payload": "3q2+7w=="}
|
|
|
|
|
|
def test_record_meshcore_message_appends_multiple(monkeypatch, tmp_path):
|
|
"""_record_meshcore_message must append successive entries on separate lines."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "DEBUG", True)
|
|
log_path = tmp_path / "ignored-meshcore.txt"
|
|
monkeypatch.setattr(_mod, "_IGNORED_MESSAGE_LOG_PATH", log_path)
|
|
|
|
_record_meshcore_message("first", source="ble")
|
|
_record_meshcore_message("second", source="ble")
|
|
|
|
lines = [l for l in log_path.read_text(encoding="utf-8").splitlines() if l]
|
|
assert len(lines) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _meshcore_node_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_meshcore_node_id_derives_from_first_four_bytes():
|
|
"""_meshcore_node_id returns !xxxxxxxx from the first 8 hex chars."""
|
|
assert _meshcore_node_id("aabbccdd" + "00" * 28) == "!aabbccdd"
|
|
|
|
|
|
def test_meshcore_node_id_lowercases_hex():
|
|
"""_meshcore_node_id must lowercase the hex digits."""
|
|
assert _meshcore_node_id("AABBCCDD" + "00" * 28) == "!aabbccdd"
|
|
|
|
|
|
def test_meshcore_node_id_none_on_empty():
|
|
"""_meshcore_node_id returns None for an empty or too-short key."""
|
|
assert _meshcore_node_id("") is None
|
|
assert _meshcore_node_id("abc") is None
|
|
assert _meshcore_node_id(None) is None # type: ignore[arg-type]
|
|
|
|
|
|
def test_meshcore_short_name_first_four_hex_digits():
|
|
"""_meshcore_short_name returns the first four hex chars, lowercased."""
|
|
assert _meshcore_short_name("AABBccdd" + "00" * 28) == "aabb"
|
|
|
|
|
|
def test_meshcore_short_name_empty_when_too_short():
|
|
"""_meshcore_short_name returns '' when the key has fewer than four hex digits."""
|
|
assert _meshcore_short_name("") == ""
|
|
assert _meshcore_short_name("abc") == ""
|
|
assert _meshcore_short_name(None) == "" # type: ignore[arg-type]
|
|
|
|
|
|
def test_meshcore_short_name_exactly_four_chars():
|
|
"""_meshcore_short_name with exactly four hex chars returns those four chars."""
|
|
assert _meshcore_short_name("abcd") == "abcd"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _pubkey_prefix_to_node_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pubkey_prefix_finds_matching_contact():
|
|
"""_pubkey_prefix_to_node_id returns the node ID for a matching prefix."""
|
|
pub_key = "aabbccddee11" + "00" * 26
|
|
contacts = {pub_key: {}}
|
|
result = _pubkey_prefix_to_node_id(contacts, "aabbccddee11")
|
|
assert result == "!aabbccdd"
|
|
|
|
|
|
def test_pubkey_prefix_returns_none_on_no_match():
|
|
"""_pubkey_prefix_to_node_id returns None when no contact matches."""
|
|
contacts = {"aabbccddee11" + "00" * 26: {}}
|
|
assert _pubkey_prefix_to_node_id(contacts, "ffeeddccbbaa") is None
|
|
|
|
|
|
def test_pubkey_prefix_returns_none_for_empty_contacts():
|
|
"""_pubkey_prefix_to_node_id returns None for an empty contacts dict."""
|
|
assert _pubkey_prefix_to_node_id({}, "aabbccddee11") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _meshcore_adv_type_to_role
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_meshcore_adv_type_to_role_maps_adv_types():
|
|
"""Known ADV_TYPE_* integers map to dashboard role strings."""
|
|
assert _meshcore_adv_type_to_role(1) == "COMPANION"
|
|
assert _meshcore_adv_type_to_role(2) == "REPEATER"
|
|
assert _meshcore_adv_type_to_role(3) == "ROOM_SERVER"
|
|
assert _meshcore_adv_type_to_role(4) == "SENSOR"
|
|
|
|
|
|
def test_meshcore_adv_type_to_role_none_for_unmapped():
|
|
"""ADV_TYPE_NONE, unknown codes, and non-integers yield None."""
|
|
assert _meshcore_adv_type_to_role(0) is None
|
|
assert _meshcore_adv_type_to_role(99) is None
|
|
assert _meshcore_adv_type_to_role(None) is None
|
|
assert _meshcore_adv_type_to_role("1") is None
|
|
assert (
|
|
_meshcore_adv_type_to_role(2.0) is None
|
|
) # float rejected; JSON numeric coercion guard
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _contact_to_node_dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_contact_to_node_dict_basic_fields():
|
|
"""_contact_to_node_dict populates user and lastHeard from a contact."""
|
|
contact = {
|
|
"public_key": "aabbccdd" + "00" * 28,
|
|
"adv_name": "Alice",
|
|
"last_advert": 1700000000,
|
|
}
|
|
node = _contact_to_node_dict(contact)
|
|
assert node["lastHeard"] == 1700000000
|
|
assert node["user"]["longName"] == "Alice"
|
|
assert node["user"]["shortName"] == "aabb"
|
|
assert node["user"]["publicKey"] == contact["public_key"]
|
|
assert "role" not in node["user"]
|
|
|
|
|
|
def test_contact_to_node_dict_sets_role_from_type():
|
|
"""Contact ``type`` must populate ``user.role`` when ADV_TYPE is mapped."""
|
|
base = {"public_key": "aabbccdd" + "00" * 28, "adv_name": "Rpt"}
|
|
assert _contact_to_node_dict({**base, "type": 2})["user"]["role"] == "REPEATER"
|
|
|
|
|
|
def test_contact_to_node_dict_omits_role_for_adv_type_none():
|
|
"""ADV_TYPE_NONE (0) must not set ``user.role``."""
|
|
contact = {
|
|
"public_key": "aabbccdd" + "00" * 28,
|
|
"adv_name": "X",
|
|
"type": 0,
|
|
}
|
|
assert "role" not in _contact_to_node_dict(contact)["user"]
|
|
|
|
|
|
def test_contact_to_node_dict_includes_position_when_nonzero():
|
|
"""_contact_to_node_dict adds position when lat/lon are non-zero."""
|
|
contact = {
|
|
"public_key": "aa" * 32,
|
|
"adv_name": "Node",
|
|
"adv_lat": 51.5,
|
|
"adv_lon": -0.1,
|
|
}
|
|
node = _contact_to_node_dict(contact)
|
|
assert "position" in node
|
|
assert node["position"]["latitude"] == pytest.approx(51.5)
|
|
assert node["position"]["longitude"] == pytest.approx(-0.1)
|
|
|
|
|
|
def test_contact_to_node_dict_omits_position_at_origin():
|
|
"""_contact_to_node_dict omits position when lat=0 and lon=0."""
|
|
contact = {
|
|
"public_key": "aa" * 32,
|
|
"adv_name": "Node",
|
|
"adv_lat": 0.0,
|
|
"adv_lon": 0.0,
|
|
}
|
|
node = _contact_to_node_dict(contact)
|
|
assert "position" not in node
|
|
|
|
|
|
def test_contact_to_node_dict_sets_protocol_meshcore():
|
|
"""_contact_to_node_dict must set protocol='meshcore' on every node dict."""
|
|
contact = {"public_key": "aa" * 32, "adv_name": "Node"}
|
|
assert _contact_to_node_dict(contact)["protocol"] == "meshcore"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _self_info_to_node_dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_self_info_to_node_dict_basic_fields():
|
|
"""_self_info_to_node_dict maps name and public_key to user dict."""
|
|
self_info = {"name": "MyNode", "public_key": "bb" * 32}
|
|
node = _self_info_to_node_dict(self_info)
|
|
assert node["user"]["longName"] == "MyNode"
|
|
assert node["user"]["shortName"] == "bbbb"
|
|
assert node["user"]["publicKey"] == "bb" * 32
|
|
assert isinstance(node["lastHeard"], int)
|
|
assert "role" not in node["user"]
|
|
|
|
|
|
def test_self_info_to_node_dict_sets_role_from_adv_type():
|
|
"""SELF_INFO ``adv_type`` must populate ``user.role`` when mapped."""
|
|
self_info = {"name": "Srv", "public_key": "cc" * 32, "adv_type": 3}
|
|
assert _self_info_to_node_dict(self_info)["user"]["role"] == "ROOM_SERVER"
|
|
|
|
|
|
def test_self_info_to_node_dict_omits_role_for_adv_type_none():
|
|
"""adv_type 0 must not set ``user.role``."""
|
|
self_info = {"name": "N", "public_key": "dd" * 32, "adv_type": 0}
|
|
assert "role" not in _self_info_to_node_dict(self_info)["user"]
|
|
|
|
|
|
def test_self_info_to_node_dict_includes_position():
|
|
"""_self_info_to_node_dict adds position when lat/lon are non-zero."""
|
|
self_info = {
|
|
"name": "N",
|
|
"public_key": "cc" * 32,
|
|
"adv_lat": 48.8,
|
|
"adv_lon": 2.35,
|
|
}
|
|
node = _self_info_to_node_dict(self_info)
|
|
assert node["position"]["latitude"] == pytest.approx(48.8)
|
|
assert node["position"]["longitude"] == pytest.approx(2.35)
|
|
|
|
|
|
def test_self_info_to_node_dict_sets_protocol_meshcore():
|
|
"""_self_info_to_node_dict must set protocol='meshcore' on the node dict."""
|
|
self_info = {"name": "MyNode", "public_key": "bb" * 32}
|
|
assert _self_info_to_node_dict(self_info)["protocol"] == "meshcore"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _MeshcoreInterface contact management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_interface_update_and_snapshot_contacts():
|
|
"""_update_contact stores contacts; contacts_snapshot returns node entries."""
|
|
iface = _MeshcoreInterface(target=None)
|
|
pub_key = "aabbccdd" + "00" * 28
|
|
iface._update_contact({"public_key": pub_key, "adv_name": "Bob", "last_advert": 1})
|
|
snapshot = iface.contacts_snapshot()
|
|
assert len(snapshot) == 1
|
|
node_id, node_dict = snapshot[0]
|
|
assert node_id == "!aabbccdd"
|
|
assert node_dict["user"]["longName"] == "Bob"
|
|
assert node_dict["user"]["shortName"] == "aabb"
|
|
|
|
|
|
def test_interface_lookup_node_id_by_prefix():
|
|
"""lookup_node_id finds a contact by its 6-byte public-key prefix."""
|
|
iface = _MeshcoreInterface(target=None)
|
|
pub_key = "aabbccddee11" + "00" * 26
|
|
iface._update_contact({"public_key": pub_key, "adv_name": "C"})
|
|
result = iface.lookup_node_id("aabbccddee11")
|
|
assert result == "!aabbccdd"
|
|
|
|
|
|
def test_interface_lookup_node_id_returns_none_on_miss():
|
|
"""lookup_node_id returns None when no contact matches the prefix."""
|
|
iface = _MeshcoreInterface(target=None)
|
|
assert iface.lookup_node_id("ffeeddccbbaa") is None
|
|
|
|
|
|
def test_interface_contacts_snapshot_skips_short_keys():
|
|
"""contacts_snapshot ignores contacts whose public_key is too short for a node ID."""
|
|
iface = _MeshcoreInterface(target=None)
|
|
iface._update_contact({"public_key": "abc", "adv_name": "Short"})
|
|
assert iface.contacts_snapshot() == []
|
|
|
|
|
|
def test_interface_close_is_idempotent():
|
|
"""_MeshcoreInterface.close() must not raise when called multiple times."""
|
|
iface = _MeshcoreInterface(target=None)
|
|
iface.close()
|
|
iface.close() # must not raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _derive_message_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_derive_message_id_is_deterministic():
|
|
"""Same inputs must always produce the same ID."""
|
|
assert _derive_message_id(1_000_000, "c0", "hello") == _derive_message_id(
|
|
1_000_000, "c0", "hello"
|
|
)
|
|
|
|
|
|
def test_derive_message_id_differs_by_channel():
|
|
"""Messages on different channels with the same timestamp must not collide."""
|
|
assert _derive_message_id(1_000_000, "c0", "hello") != _derive_message_id(
|
|
1_000_000, "c1", "hello"
|
|
)
|
|
|
|
|
|
def test_derive_message_id_differs_by_text():
|
|
"""Messages with different text must produce different IDs."""
|
|
assert _derive_message_id(1_000_000, "c0", "hello") != _derive_message_id(
|
|
1_000_000, "c0", "world"
|
|
)
|
|
|
|
|
|
def test_derive_message_id_differs_by_timestamp():
|
|
"""Messages at different timestamps must produce different IDs."""
|
|
assert _derive_message_id(1_000_000, "c0", "hi") != _derive_message_id(
|
|
1_000_001, "c0", "hi"
|
|
)
|
|
|
|
|
|
def test_derive_message_id_is_32bit():
|
|
"""Result must fit in a 32-bit unsigned integer."""
|
|
result = _derive_message_id(1_758_000_000, "aabbccddee11", "some text")
|
|
assert 0 <= result <= 0xFFFFFFFF
|
|
|
|
|
|
def test_derive_message_id_distinguishes_long_messages_differing_after_128_chars():
|
|
"""Messages that share the first 128 characters must still get different IDs."""
|
|
prefix = "A" * 128
|
|
id_a = _derive_message_id(1_000_000, "c0", prefix + "AAAAAA")
|
|
id_b = _derive_message_id(1_000_000, "c0", prefix + "BBBBBB")
|
|
assert id_a != id_b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _make_event_handlers — async callbacks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_stub_handlers_module():
|
|
"""Return a minimal stub for data.mesh_ingestor.handlers."""
|
|
import types
|
|
|
|
mod = types.SimpleNamespace(
|
|
upsert_node=lambda *_a, **_k: None,
|
|
register_host_node_id=lambda *_a, **_k: None,
|
|
_mark_packet_seen=lambda: None,
|
|
store_packet_dict=lambda *_a, **_k: None,
|
|
)
|
|
return mod
|
|
|
|
|
|
def test_on_channel_msg_queues_packet(monkeypatch):
|
|
"""on_channel_msg must call store_packet_dict with the correct packet fields."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
captured: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.store_packet_dict = lambda pkt: captured.append(pkt)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
# _make_event_handlers does `from .. import handlers`; patch the package attr
|
|
# so the deferred import resolves to our stub without touching sys.modules.
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
class _FakeEvt:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(
|
|
hmap["CHANNEL_MSG_RECV"](
|
|
_FakeEvt(
|
|
{
|
|
"sender_timestamp": 1_758_000_000,
|
|
"text": "hello mesh",
|
|
"channel_idx": 2,
|
|
"SNR": 5,
|
|
"RSSI": -80,
|
|
}
|
|
)
|
|
)
|
|
)
|
|
|
|
assert len(captured) == 1
|
|
pkt = captured[0]
|
|
assert pkt["decoded"]["text"] == "hello mesh"
|
|
assert pkt["channel"] == 2
|
|
assert pkt["to_id"] == "^all"
|
|
assert pkt["from_id"] is None
|
|
assert pkt["snr"] == 5
|
|
assert pkt["rssi"] == -80
|
|
# ID must be the hash-derived value, not the raw timestamp
|
|
assert pkt["id"] == _derive_message_id(1_758_000_000, "c2", "hello mesh")
|
|
|
|
|
|
def test_on_contact_msg_queues_packet_with_from_id(monkeypatch):
|
|
"""on_contact_msg must resolve from_id via pubkey_prefix and set to_id to host."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
captured: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.store_packet_dict = lambda pkt: captured.append(pkt)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
pub_key = "aabbccddee11" + "00" * 26
|
|
iface = _MeshcoreInterface(target=None)
|
|
iface.host_node_id = "!deadbeef"
|
|
iface._update_contact({"public_key": pub_key, "adv_name": "Alice"})
|
|
|
|
class _FakeEvt:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(
|
|
hmap["CONTACT_MSG_RECV"](
|
|
_FakeEvt(
|
|
{
|
|
"sender_timestamp": 1_758_000_001,
|
|
"text": "direct message",
|
|
"pubkey_prefix": "aabbccddee11",
|
|
"SNR": 3,
|
|
}
|
|
)
|
|
)
|
|
)
|
|
|
|
assert len(captured) == 1
|
|
pkt = captured[0]
|
|
assert pkt["decoded"]["text"] == "direct message"
|
|
assert pkt["from_id"] == "!aabbccdd"
|
|
assert pkt["to_id"] == "!deadbeef"
|
|
assert pkt["id"] == _derive_message_id(
|
|
1_758_000_001, "aabbccddee11", "direct message"
|
|
)
|
|
|
|
|
|
def test_on_channel_msg_skips_empty_text(monkeypatch):
|
|
"""on_channel_msg must not queue a packet when text is absent."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
captured: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.store_packet_dict = lambda pkt: captured.append(pkt)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
|
|
class _FakeEvt:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(hmap["CHANNEL_MSG_RECV"](_FakeEvt({"sender_timestamp": 1, "text": ""})))
|
|
asyncio.run(hmap["CHANNEL_MSG_RECV"](_FakeEvt({"text": "hi"}))) # missing ts
|
|
|
|
assert captured == []
|
|
|
|
|
|
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
|
|
def test_connect_raises_on_timeout(monkeypatch):
|
|
"""connect() raises ConnectionError when connected_event is never signalled.
|
|
|
|
The background thread's event loop is stopped by iface.close() while the
|
|
mock coroutine is still suspended; the resulting RuntimeError in that thread
|
|
is expected and suppressed via the filterwarnings mark above.
|
|
"""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
async def _hanging(iface, target, connected_event, error_holder):
|
|
# Never signals connected_event — simulates a device that does not respond.
|
|
import asyncio as _aio
|
|
|
|
await _aio.sleep(60)
|
|
|
|
monkeypatch.setattr(_mod, "_run_meshcore", _hanging)
|
|
monkeypatch.setattr(_mod, "_CONNECT_TIMEOUT_SECS", 0.05)
|
|
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
|
|
with pytest.raises(ConnectionError, match="Timed out"):
|
|
MeshcoreProvider().connect(active_candidate="/dev/ttyUSB0")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _to_json_safe
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_to_json_safe_primitives():
|
|
"""Primitive JSON types pass through unchanged."""
|
|
assert _to_json_safe("hello") == "hello"
|
|
assert _to_json_safe(42) == 42
|
|
assert _to_json_safe(3.14) == pytest.approx(3.14)
|
|
assert _to_json_safe(True) is True
|
|
assert _to_json_safe(None) is None
|
|
|
|
|
|
def test_to_json_safe_bytes_base64():
|
|
"""bytes values are base64-encoded to an ASCII string."""
|
|
import base64
|
|
|
|
raw = b"\xde\xad\xbe\xef"
|
|
result = _to_json_safe(raw)
|
|
assert result == base64.b64encode(raw).decode("ascii")
|
|
|
|
|
|
def test_to_json_safe_nested_dict():
|
|
"""Dicts are recursively converted; bytes leaves are base64-encoded."""
|
|
result = _to_json_safe({"a": 1, "payload": b"\x00\x01"})
|
|
assert result["a"] == 1
|
|
assert isinstance(result["payload"], str)
|
|
|
|
|
|
def test_to_json_safe_list_and_tuple_and_set():
|
|
"""Lists, tuples, and sets are converted to JSON arrays."""
|
|
assert _to_json_safe([1, 2]) == [1, 2]
|
|
assert _to_json_safe((3, 4)) == [3, 4]
|
|
result = _to_json_safe({5})
|
|
assert isinstance(result, list)
|
|
assert 5 in result
|
|
|
|
|
|
def test_to_json_safe_unknown_type_stringified():
|
|
"""Unknown types are coerced to str."""
|
|
|
|
class _Custom:
|
|
def __str__(self) -> str:
|
|
return "custom-repr"
|
|
|
|
assert _to_json_safe(_Custom()) == "custom-repr"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_self_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_process_self_info_sets_host_node_id():
|
|
"""_process_self_info must set iface.host_node_id and call register_host_node_id."""
|
|
stub = _make_stub_handlers_module()
|
|
registered: list = []
|
|
stub.register_host_node_id = lambda nid: registered.append(nid)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
_process_self_info(
|
|
{"public_key": "aabbccdd" + "00" * 28, "name": "Host"}, iface, stub
|
|
)
|
|
|
|
assert iface.host_node_id == "!aabbccdd"
|
|
assert registered == ["!aabbccdd"]
|
|
|
|
|
|
def test_process_self_info_skips_empty_key():
|
|
"""_process_self_info must not set host_node_id when public_key is absent."""
|
|
stub = _make_stub_handlers_module()
|
|
registered: list = []
|
|
stub.register_host_node_id = lambda nid: registered.append(nid)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
_process_self_info({"public_key": "", "name": "Unknown"}, iface, stub)
|
|
|
|
assert iface.host_node_id is None
|
|
assert registered == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_contacts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_process_contacts_updates_snapshot_and_upserts():
|
|
"""_process_contacts must update the iface snapshot and call upsert_node."""
|
|
stub = _make_stub_handlers_module()
|
|
upserted: list = []
|
|
stub.upsert_node = lambda nid, nd: upserted.append(nid)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
pub_key = "aabbccdd" + "00" * 28
|
|
_process_contacts(
|
|
{pub_key: {"public_key": pub_key, "adv_name": "Alice"}}, iface, stub
|
|
)
|
|
|
|
assert upserted == ["!aabbccdd"]
|
|
assert len(iface.contacts_snapshot()) == 1
|
|
|
|
|
|
def test_process_contacts_skips_short_keys():
|
|
"""_process_contacts must ignore contacts whose public_key is too short."""
|
|
stub = _make_stub_handlers_module()
|
|
upserted: list = []
|
|
stub.upsert_node = lambda nid, nd: upserted.append(nid)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
_process_contacts({"abc": {"adv_name": "Short"}}, iface, stub)
|
|
|
|
assert upserted == []
|
|
|
|
|
|
def test_process_contacts_marks_packet_seen():
|
|
"""_process_contacts must always call _mark_packet_seen."""
|
|
seen: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub._mark_packet_seen = lambda: seen.append(True)
|
|
|
|
_process_contacts({}, _MeshcoreInterface(target=None), stub)
|
|
|
|
assert seen == [True]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_contact_update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_process_contact_update_upserts_node(monkeypatch):
|
|
"""_process_contact_update must upsert and update the snapshot."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
|
|
stub = _make_stub_handlers_module()
|
|
upserted: list = []
|
|
stub.upsert_node = lambda nid, nd: upserted.append(nid)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
pub_key = "aabbccdd" + "00" * 28
|
|
_process_contact_update({"public_key": pub_key, "adv_name": "Bob"}, iface, stub)
|
|
|
|
assert upserted == ["!aabbccdd"]
|
|
assert len(iface.contacts_snapshot()) == 1
|
|
|
|
|
|
def test_process_contact_update_skips_empty_key(monkeypatch):
|
|
"""_process_contact_update must silently skip contacts without a valid key."""
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
|
|
stub = _make_stub_handlers_module()
|
|
upserted: list = []
|
|
stub.upsert_node = lambda nid, nd: upserted.append(nid)
|
|
|
|
_process_contact_update(
|
|
{"public_key": "", "adv_name": "Bad"}, _MeshcoreInterface(target=None), stub
|
|
)
|
|
|
|
assert upserted == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_self_info via _make_event_handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_on_self_info_registers_and_upserts(monkeypatch):
|
|
"""SELF_INFO handler must register the host node and upsert it."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
registered: list = []
|
|
upserted: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.register_host_node_id = lambda nid: registered.append(nid)
|
|
stub.upsert_node = lambda nid, nd: upserted.append(nid)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
class _Evt:
|
|
payload = {"public_key": "aabbccdd" + "00" * 28, "name": "MyNode"}
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(hmap["SELF_INFO"](_Evt()))
|
|
|
|
assert iface.host_node_id == "!aabbccdd"
|
|
assert registered == ["!aabbccdd"]
|
|
assert upserted == ["!aabbccdd"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_contacts via _make_event_handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_on_contacts_updates_contacts(monkeypatch):
|
|
"""CONTACTS handler must populate the iface contact snapshot."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
upserted: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.upsert_node = lambda nid, nd: upserted.append(nid)
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
pub_key = "aabbccdd" + "00" * 28
|
|
|
|
class _Evt:
|
|
payload = {pub_key: {"public_key": pub_key, "adv_name": "C"}}
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(hmap["CONTACTS"](_Evt()))
|
|
|
|
assert "!aabbccdd" in upserted
|
|
assert len(iface.contacts_snapshot()) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_contact_update (NEW_CONTACT / NEXT_CONTACT) via _make_event_handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_on_new_contact_and_next_contact_update_iface(monkeypatch):
|
|
"""NEW_CONTACT and NEXT_CONTACT must both update the contact snapshot."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
upserted: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.upsert_node = lambda nid, nd: upserted.append(nid)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
pub_key = "aabbccdd" + "00" * 28
|
|
|
|
class _Evt:
|
|
payload = {"public_key": pub_key, "adv_name": "D"}
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(hmap["NEW_CONTACT"](_Evt()))
|
|
asyncio.run(hmap["NEXT_CONTACT"](_Evt()))
|
|
|
|
assert upserted.count("!aabbccdd") == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_disconnected via _make_event_handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_on_disconnected_clears_connected_flag(monkeypatch):
|
|
"""DISCONNECTED handler must set iface.isConnected to False."""
|
|
import asyncio
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
|
|
class _Evt:
|
|
payload = {}
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
iface.isConnected = True
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(hmap["DISCONNECTED"](_Evt()))
|
|
|
|
assert iface.isConnected is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# protocol field in emitted packets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_on_channel_msg_includes_protocol_meshcore(monkeypatch):
|
|
"""Channel message packets must carry protocol='meshcore'."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
captured: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.store_packet_dict = lambda pkt: captured.append(pkt)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
class _Evt:
|
|
payload = {"sender_timestamp": 1_000_000, "text": "ping", "channel_idx": 0}
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(hmap["CHANNEL_MSG_RECV"](_Evt()))
|
|
|
|
assert len(captured) == 1, "expected exactly one packet to be captured"
|
|
assert captured[0]["protocol"] == "meshcore"
|
|
|
|
|
|
def test_on_contact_msg_includes_protocol_meshcore(monkeypatch):
|
|
"""Direct message packets must carry protocol='meshcore'."""
|
|
import asyncio
|
|
import data.mesh_ingestor as _mesh_pkg
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
captured: list = []
|
|
stub = _make_stub_handlers_module()
|
|
stub.store_packet_dict = lambda pkt: captured.append(pkt)
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
iface.host_node_id = "!deadbeef"
|
|
|
|
class _Evt:
|
|
payload = {
|
|
"sender_timestamp": 1_000_001,
|
|
"text": "direct",
|
|
"pubkey_prefix": "",
|
|
}
|
|
|
|
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
|
asyncio.run(hmap["CONTACT_MSG_RECV"](_Evt()))
|
|
|
|
assert len(captured) == 1, "expected exactly one packet to be captured"
|
|
assert captured[0]["protocol"] == "meshcore"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _run_meshcore — full coroutine paths (fake meshcore module in sys.modules)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_fake_meshcore_mod(
|
|
*,
|
|
connect_result: object = "ok",
|
|
fail_ensure_contacts: bool = False,
|
|
disconnect_raises: bool = False,
|
|
connect_stall_event=None,
|
|
):
|
|
"""Build a minimal fake ``meshcore`` module for testing :func:`_run_meshcore`.
|
|
|
|
Parameters:
|
|
connect_result: Value returned by ``mc.connect()``. Pass ``None`` to
|
|
simulate a device that ignores the appstart handshake.
|
|
fail_ensure_contacts: When ``True``, ``mc.ensure_contacts()`` raises a
|
|
``RuntimeError``.
|
|
disconnect_raises: When ``True``, ``mc.disconnect()`` raises, exercising
|
|
the ``finally`` exception-suppression path.
|
|
connect_stall_event: Optional :class:`asyncio.Event`; when set,
|
|
``connect()`` awaits it (never completes unless the event is set).
|
|
"""
|
|
import enum
|
|
|
|
EventType = enum.Enum(
|
|
"EventType",
|
|
[
|
|
"SELF_INFO",
|
|
"CONTACTS",
|
|
"NEW_CONTACT",
|
|
"NEXT_CONTACT",
|
|
"CHANNEL_MSG_RECV",
|
|
"CONTACT_MSG_RECV",
|
|
"DISCONNECTED",
|
|
"CONNECTED",
|
|
"ACK",
|
|
"OK",
|
|
"ERROR",
|
|
"NO_MORE_MSGS",
|
|
"MESSAGES_WAITING",
|
|
"MSG_SENT",
|
|
"CURRENT_TIME",
|
|
"UNKNOWN_EVT",
|
|
],
|
|
)
|
|
|
|
class _FakeMeshCore:
|
|
def __init__(self, cx):
|
|
self._catch_all = None
|
|
|
|
def subscribe(self, event_type, callback):
|
|
if event_type is None:
|
|
self._catch_all = callback
|
|
|
|
async def connect(self):
|
|
if connect_stall_event is not None:
|
|
await connect_stall_event.wait()
|
|
return connect_result
|
|
|
|
async def ensure_contacts(self):
|
|
if fail_ensure_contacts:
|
|
raise RuntimeError("contacts unavailable")
|
|
|
|
async def start_auto_message_fetching(self):
|
|
pass
|
|
|
|
async def disconnect(self):
|
|
if disconnect_raises:
|
|
raise RuntimeError("disconnect failed")
|
|
|
|
class _FakeSerialConnection:
|
|
def __init__(self, target, baudrate):
|
|
pass
|
|
|
|
class _FakeBLEConnection:
|
|
def __init__(self, address=None):
|
|
pass
|
|
|
|
class _FakeTCPConnection:
|
|
def __init__(self, host, port):
|
|
pass
|
|
|
|
return types.SimpleNamespace(
|
|
EventType=EventType,
|
|
MeshCore=_FakeMeshCore,
|
|
SerialConnection=_FakeSerialConnection,
|
|
BLEConnection=_FakeBLEConnection,
|
|
TCPConnection=_FakeTCPConnection,
|
|
)
|
|
|
|
|
|
async def _run_until_connected(iface, target, fake_mod, mod):
|
|
"""Drive :func:`_run_meshcore` in a task and return once connected (or failed)."""
|
|
import asyncio as _aio
|
|
import threading
|
|
|
|
connected_event = threading.Event()
|
|
error_holder: list = [None]
|
|
task = _aio.create_task(
|
|
mod._run_meshcore(iface, target, connected_event, error_holder)
|
|
)
|
|
for _ in range(100):
|
|
await _aio.sleep(0)
|
|
if connected_event.is_set():
|
|
break
|
|
if iface._stop_event is not None:
|
|
iface._stop_event.set()
|
|
await task
|
|
return connected_event, error_holder
|
|
|
|
|
|
def _setup_stalled_run(monkeypatch):
|
|
"""Shared setup for tests that need a *_run_meshcore* stalled at ``connect()``.
|
|
|
|
Patches ``_debug_log``, builds a fake ``meshcore`` module whose
|
|
``connect()`` blocks on a :class:`asyncio.Event`, and injects it into
|
|
``sys.modules``.
|
|
|
|
Returns:
|
|
tuple: ``(stall, _mod)`` where *stall* is the event that, when set,
|
|
lets ``connect()`` proceed, and *_mod* is the imported provider module.
|
|
"""
|
|
import asyncio
|
|
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
stall = asyncio.Event()
|
|
fake_mod = _make_fake_meshcore_mod(connect_stall_event=stall)
|
|
monkeypatch.setitem(sys.modules, "meshcore", fake_mod)
|
|
return stall, _mod
|
|
|
|
|
|
async def _start_stalled_run(mod):
|
|
"""Start *_run_meshcore* in a task and spin until ``_stop_event`` is installed.
|
|
|
|
``_stop_event`` is guaranteed to be set before the first ``await`` inside
|
|
``connect()``, so after this coroutine returns the task is parked inside
|
|
the stall event and ``iface._stop_event`` is ready for use.
|
|
|
|
Returns:
|
|
tuple: ``(iface, connected_event, error_holder, task)``
|
|
"""
|
|
import asyncio
|
|
import threading
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
connected_event = threading.Event()
|
|
error_holder: list = [None]
|
|
task = asyncio.create_task(
|
|
mod._run_meshcore(iface, "/dev/ttyUSB0", connected_event, error_holder)
|
|
)
|
|
for _ in range(500):
|
|
await asyncio.sleep(0)
|
|
if iface._stop_event is not None:
|
|
break
|
|
return iface, connected_event, error_holder, task
|
|
|
|
|
|
def test_run_meshcore_stop_event_before_connect_finishes(monkeypatch):
|
|
"""_stop_event must exist before connect() returns so iface.close() avoids loop.stop()."""
|
|
import asyncio
|
|
|
|
stall, _mod = _setup_stalled_run(monkeypatch)
|
|
|
|
async def _runner() -> None:
|
|
iface, connected_event, _err, task = await _start_stalled_run(_mod)
|
|
assert (
|
|
iface._stop_event is not None
|
|
), "_stop_event must be set before connect() completes"
|
|
assert not connected_event.is_set()
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
asyncio.run(_runner())
|
|
|
|
|
|
def test_run_meshcore_close_before_connect_completes(monkeypatch):
|
|
"""close() while connect() is stalled must surface ClosedBeforeConnectedError.
|
|
|
|
The coroutine should:
|
|
- Set ``connected_event`` so the caller is unblocked.
|
|
- Store a :class:`ClosedBeforeConnectedError` in ``error_holder[0]``.
|
|
- Leave ``iface.isConnected`` as ``False``.
|
|
"""
|
|
import asyncio
|
|
|
|
stall, _mod = _setup_stalled_run(monkeypatch)
|
|
|
|
async def _runner() -> None:
|
|
iface, connected_event, error_holder, task = await _start_stalled_run(_mod)
|
|
assert iface._stop_event is not None
|
|
|
|
# Signal shutdown then let connect() return — simulating iface.close()
|
|
# being called while the device handshake is still in flight.
|
|
iface._stop_event.set()
|
|
stall.set()
|
|
await task
|
|
|
|
assert connected_event.is_set(), "connected_event must be set to unblock caller"
|
|
assert isinstance(
|
|
error_holder[0], _mod.ClosedBeforeConnectedError
|
|
), "error_holder must contain ClosedBeforeConnectedError"
|
|
assert isinstance(
|
|
error_holder[0], ConnectionError
|
|
), "ClosedBeforeConnectedError must be a ConnectionError subclass"
|
|
assert iface.isConnected is False, "isConnected must remain False"
|
|
|
|
asyncio.run(_runner())
|
|
|
|
|
|
def test_run_meshcore_happy_path(monkeypatch):
|
|
"""_run_meshcore must signal connected and leave isConnected=True on success."""
|
|
import asyncio
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
fake_mod = _make_fake_meshcore_mod()
|
|
monkeypatch.setitem(sys.modules, "meshcore", fake_mod)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
|
|
connected_event, error_holder = asyncio.run(
|
|
_run_until_connected(iface, "/dev/ttyUSB0", fake_mod, _mod)
|
|
)
|
|
|
|
assert connected_event.is_set()
|
|
assert error_holder[0] is None
|
|
assert iface.isConnected is True
|
|
|
|
|
|
def test_run_meshcore_connect_returns_none_raises(monkeypatch):
|
|
"""_run_meshcore must propagate ConnectionError when connect() returns None."""
|
|
import asyncio
|
|
import threading
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
fake_mod = _make_fake_meshcore_mod(connect_result=None)
|
|
monkeypatch.setitem(sys.modules, "meshcore", fake_mod)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
connected_event = threading.Event()
|
|
error_holder: list = [None]
|
|
|
|
asyncio.run(
|
|
_mod._run_meshcore(iface, "/dev/ttyUSB0", connected_event, error_holder)
|
|
)
|
|
|
|
assert connected_event.is_set()
|
|
assert isinstance(error_holder[0], ConnectionError)
|
|
assert "appstart" in str(error_holder[0])
|
|
|
|
|
|
def test_run_meshcore_ensure_contacts_failure_continues(monkeypatch):
|
|
"""ensure_contacts() raising must log a warning but not abort the connection."""
|
|
import asyncio
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
logged: list = []
|
|
monkeypatch.setattr(
|
|
_mod.config,
|
|
"_debug_log",
|
|
lambda *_a, severity=None, **_k: logged.append(severity),
|
|
)
|
|
fake_mod = _make_fake_meshcore_mod(fail_ensure_contacts=True)
|
|
monkeypatch.setitem(sys.modules, "meshcore", fake_mod)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
|
|
connected_event, error_holder = asyncio.run(
|
|
_run_until_connected(iface, "/dev/ttyUSB0", fake_mod, _mod)
|
|
)
|
|
|
|
assert connected_event.is_set()
|
|
assert error_holder[0] is None
|
|
assert "warning" in logged
|
|
|
|
|
|
def test_run_meshcore_disconnect_exception_suppressed(monkeypatch):
|
|
"""disconnect() raising in the finally block must be silently swallowed."""
|
|
import asyncio
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
fake_mod = _make_fake_meshcore_mod(disconnect_raises=True)
|
|
monkeypatch.setitem(sys.modules, "meshcore", fake_mod)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
|
|
connected_event, error_holder = asyncio.run(
|
|
_run_until_connected(iface, "/dev/ttyUSB0", fake_mod, _mod)
|
|
)
|
|
|
|
assert connected_event.is_set()
|
|
assert error_holder[0] is None
|
|
|
|
|
|
def test_run_meshcore_on_unhandled_skips_known_records_unknown(monkeypatch):
|
|
"""_on_unhandled must only call _record_meshcore_message for truly unknown events."""
|
|
import asyncio
|
|
import data.mesh_ingestor.providers.meshcore as _mod
|
|
|
|
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
|
recorded: list = []
|
|
monkeypatch.setattr(
|
|
_mod,
|
|
"_record_meshcore_message",
|
|
lambda msg, *, source: recorded.append(source),
|
|
)
|
|
fake_mod = _make_fake_meshcore_mod()
|
|
monkeypatch.setitem(sys.modules, "meshcore", fake_mod)
|
|
|
|
iface = _MeshcoreInterface(target=None)
|
|
|
|
async def _exercise():
|
|
import asyncio as _aio
|
|
import threading
|
|
|
|
connected_event = threading.Event()
|
|
error_holder: list = [None]
|
|
task = _aio.create_task(
|
|
_mod._run_meshcore(iface, "/dev/ttyUSB0", connected_event, error_holder)
|
|
)
|
|
for _ in range(100):
|
|
await _aio.sleep(0)
|
|
if connected_event.is_set():
|
|
break
|
|
|
|
mc = iface._mc
|
|
EventType = fake_mod.EventType
|
|
|
|
class _Evt:
|
|
def __init__(self, etype, payload=None):
|
|
self.type = etype
|
|
self.payload = payload
|
|
|
|
# Handled type → no record
|
|
await mc._catch_all(_Evt(EventType.SELF_INFO))
|
|
# Silent type → no record
|
|
await mc._catch_all(_Evt(EventType.ACK))
|
|
# Truly unknown type → must be recorded
|
|
await mc._catch_all(_Evt(EventType.UNKNOWN_EVT, payload={"x": 1}))
|
|
|
|
if iface._stop_event:
|
|
iface._stop_event.set()
|
|
await task
|
|
|
|
asyncio.run(_exercise())
|
|
|
|
assert len(recorded) == 1, "only the unknown event should be recorded"
|
|
assert "UNKNOWN_EVT" in recorded[0]
|