mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 09:22:53 +02:00
data: provide frequency and modem preset for meshcore (#694)
* data: provide frequency and modem preset for meshcore * data: provide frequency and modem preset for meshcore * ingestor: address review comments * fix: remove duplicate _mark_packet_seen entry from handlers __all__ * ci: install meshcore in Python workflow protocols/meshcore.py now imports meshcore at module level (required to fix a self-referential import failure after the providers/ → protocols/ rename). test_provider_unit.py imports that module unconditionally, so meshcore must be present in the test environment. * data: run black
This commit is contained in:
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black pytest pytest-cov meshtastic
|
||||
pip install black pytest pytest-cov meshtastic meshcore
|
||||
- name: Test with pytest and coverage
|
||||
run: |
|
||||
mkdir -p reports
|
||||
|
||||
@@ -15,7 +15,7 @@ Run linters for Python (`black`) and Ruby (`rufo`) to ensure consistent code for
|
||||
## Project Structure & Module Organization
|
||||
The repository splits runtime and ingestion logic. `web/` holds the Sinatra dashboard (Ruby code in `lib/potato_mesh`, views in `views/`, static bundles in `public/`).
|
||||
|
||||
`data/` hosts the Python Meshtastic ingestor plus migrations and CLI scripts. The ingestor is structured as the `data/mesh_ingestor/` package with the following key modules: `daemon.py` (main loop), `handlers.py` (packet processing), `interfaces.py` (interface helpers), `config.py` (env-driven config), `events.py` (TypedDict event schemas), `provider.py` (Provider protocol), `node_identity.py` (canonical node ID utilities), `decode_payload.py` (CLI protobuf decoder), and the `providers/` subpackage (currently `meshtastic.py`). API contracts for all POST ingest routes are documented in `data/mesh_ingestor/CONTRACTS.md`. API fixtures and end-to-end harnesses live in `tests/`. Dockerfiles and compose files support containerized workflows.
|
||||
`data/` hosts the Python Meshtastic ingestor plus migrations and CLI scripts. The ingestor is structured as the `data/mesh_ingestor/` package with the following key modules: `daemon.py` (main loop), `handlers.py` (packet processing), `interfaces.py` (interface helpers), `config.py` (env-driven config), `events.py` (TypedDict event schemas), `mesh_protocol.py` (MeshProtocol base), `node_identity.py` (canonical node ID utilities), `decode_payload.py` (CLI protobuf decoder), and the `protocols/` subpackage (currently `meshtastic.py`). API contracts for all POST ingest routes are documented in `data/mesh_ingestor/CONTRACTS.md`. API fixtures and end-to-end harnesses live in `tests/`. Dockerfiles and compose files support containerized workflows.
|
||||
|
||||
`matrix/` contains the Rust Matrix bridge; build with `cargo build --release` or `docker build -f matrix/Dockerfile .`, and keep bridge config under `matrix/Config.toml` when running locally.
|
||||
|
||||
@@ -41,15 +41,15 @@ Ruby specs run with `cd web && bundle exec rspec`, producing SimpleCov output in
|
||||
|
||||
The ingestion layer is tested with `pytest -q tests/`; leave fixtures in `tests/` untouched so CI can replay them. The suite includes both integration tests (`test_mesh.py`) and focused unit tests — `test_events_unit.py` (TypedDict schemas), `test_provider_unit.py` (Provider protocol conformance and `MeshtasticProvider`), `test_node_identity_unit.py` (canonical ID helpers), `test_daemon_unit.py`, `test_serialization_unit.py`, and `test_decode_payload.py`. New features should ship with matching specs and updated integration checks.
|
||||
|
||||
## Adding a New Ingestor Provider
|
||||
The `data/mesh_ingestor/provider.py` module defines a `@runtime_checkable` `Provider` Protocol with five members: `name` (str), `subscribe()`, `connect(*, active_candidate)`, `extract_host_node_id(iface)`, and `node_snapshot_items(iface)`. To add a new backend (e.g. Reticulum, MeshCore):
|
||||
## Adding a New Ingestor Protocol
|
||||
The `data/mesh_ingestor/mesh_protocol.py` module defines a `@runtime_checkable` `MeshProtocol` class with five members: `name` (str), `subscribe()`, `connect(*, active_candidate)`, `extract_host_node_id(iface)`, and `node_snapshot_items(iface)`. To add a new backend (e.g. Reticulum):
|
||||
|
||||
1. Create `data/mesh_ingestor/providers/<name>.py` with a class satisfying the Protocol.
|
||||
2. Register it in `data/mesh_ingestor/providers/__init__.py`.
|
||||
1. Create `data/mesh_ingestor/protocols/<name>.py` with a class satisfying the `MeshProtocol` interface.
|
||||
2. Register it in `data/mesh_ingestor/protocols/__init__.py`.
|
||||
3. Pass an instance via `daemon.main(provider=...)` or make it the default in `main()`.
|
||||
4. Cover the provider with unit tests in `tests/test_provider_unit.py` — at minimum an `isinstance(..., Provider)` conformance check and any retry/error-handling paths.
|
||||
4. Cover the protocol with unit tests in `tests/test_provider_unit.py` — at minimum an `isinstance(..., MeshProtocol)` conformance check and any retry/error-handling paths.
|
||||
|
||||
Consult `data/mesh_ingestor/CONTRACTS.md` for the canonical event shapes all providers must emit.
|
||||
Consult `data/mesh_ingestor/CONTRACTS.md` for the canonical event shapes all protocols must emit.
|
||||
|
||||
## GitHub Configuration Standards
|
||||
Every language used in the repository must have a Dependabot entry checking for dependency updates on a **weekly** schedule. Keep the Dependabot config up to date as new languages or package ecosystems are added.
|
||||
|
||||
+11
-5
@@ -15,8 +15,14 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -U pip
|
||||
pip install -r "$(dirname "$0")/requirements.txt"
|
||||
exec python mesh.py
|
||||
# Recreate the venv only when its embedded Python is missing or points to the
|
||||
# wrong prefix (e.g. a stale shebang from a sibling project's venv). Avoid
|
||||
# --clear on every run: it wipes installed packages before each start, so any
|
||||
# restart during a PyPI outage turns a transient network failure into hard
|
||||
# ingestor downtime.
|
||||
if ! .venv/bin/python -c "import sys; exit(0 if '.venv' in sys.prefix else 1)" 2>/dev/null; then
|
||||
python -m venv --clear .venv
|
||||
fi
|
||||
.venv/bin/pip install -U pip
|
||||
.venv/bin/pip install -r "$(dirname "$0")/requirements.txt"
|
||||
exec .venv/bin/python mesh.py
|
||||
|
||||
@@ -5,7 +5,7 @@ This repo’s ingestion pipeline is split into:
|
||||
- **Python collector** (`data/mesh_ingestor/*`) which normalizes packets/events and POSTs JSON to the web app.
|
||||
- **Sinatra web app** (`web/`) which accepts those payloads on `POST /api/*` ingest routes and persists them into SQLite tables defined under `data/*.sql`.
|
||||
|
||||
This document records the **contracts that future providers must preserve**. The intent is to enable adding new providers (MeshCore, Reticulum, …) without changing the Ruby/DB/UI read-side.
|
||||
This document records the **contracts that future protocols must preserve**. The intent is to enable adding new protocols (MeshCore, Reticulum, …) without changing the Ruby/DB/UI read-side.
|
||||
|
||||
### Canonical node identity
|
||||
|
||||
@@ -16,7 +16,7 @@ This document records the **contracts that future providers must preserve**. The
|
||||
- Ruby normalizes via `web/lib/potato_mesh/application/data_processing.rb:canonical_node_parts`.
|
||||
- **Dual addressing**: Ruby routes and queries accept either a canonical `!xxxxxxxx` string or a numeric node id; they normalize to `node_id`.
|
||||
|
||||
Note: non-Meshtastic providers will need a strategy to map their native node identifiers into this `!%08x` space. That mapping is intentionally not standardized in code yet.
|
||||
Note: non-Meshtastic protocols will need a strategy to map their native node identifiers into this `!%08x` space. That mapping is intentionally not standardized in code yet.
|
||||
|
||||
### Ingest HTTP routes and payload shapes
|
||||
|
||||
|
||||
@@ -65,21 +65,31 @@ CHANNEL_INDEX = int(os.environ.get("CHANNEL_INDEX", str(DEFAULT_CHANNEL_INDEX)))
|
||||
|
||||
DEBUG = os.environ.get("DEBUG") == "1"
|
||||
|
||||
_KNOWN_PROVIDERS = ("meshtastic", "meshcore")
|
||||
_KNOWN_PROTOCOLS = ("meshtastic", "meshcore")
|
||||
|
||||
_raw_provider = os.environ.get("PROVIDER", "meshtastic").strip().lower()
|
||||
if _raw_provider not in _KNOWN_PROVIDERS:
|
||||
# Prefer the canonical PROTOCOL env var; fall back to legacy PROVIDER for
|
||||
# backwards compatibility with existing deployments.
|
||||
_raw_protocol = (
|
||||
(os.environ.get("PROTOCOL") or os.environ.get("PROVIDER", "meshtastic"))
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if _raw_protocol not in _KNOWN_PROTOCOLS:
|
||||
raise ValueError(
|
||||
f"Unknown PROVIDER={_raw_provider!r}. "
|
||||
f"Valid options: {', '.join(_KNOWN_PROVIDERS)}"
|
||||
f"Unknown PROTOCOL={_raw_protocol!r}. "
|
||||
f"Valid options: {', '.join(_KNOWN_PROTOCOLS)}"
|
||||
)
|
||||
|
||||
PROVIDER = _raw_provider
|
||||
"""Active ingestion provider, selected via the :envvar:`PROVIDER` environment variable.
|
||||
PROTOCOL = _raw_protocol
|
||||
"""Active ingestion protocol, selected via the :envvar:`PROTOCOL` environment variable.
|
||||
|
||||
Accepted values are ``meshtastic`` (default) and ``meshcore``.
|
||||
The legacy :envvar:`PROVIDER` environment variable is still accepted as a fallback.
|
||||
"""
|
||||
|
||||
PROVIDER = PROTOCOL
|
||||
"""Deprecated alias for :data:`PROTOCOL`; kept for backwards compatibility."""
|
||||
|
||||
|
||||
def _parse_channel_names(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Normalise a comma-separated list of channel names.
|
||||
@@ -228,12 +238,16 @@ class _ConfigModule(ModuleType):
|
||||
"""Module proxy that keeps connection aliases synchronised."""
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None: # type: ignore[override]
|
||||
"""Propagate CONNECTION/PORT assignments to both attributes."""
|
||||
"""Propagate CONNECTION/PORT and PROTOCOL/PROVIDER assignments to both attributes."""
|
||||
|
||||
if name in {"CONNECTION", "PORT"}:
|
||||
super().__setattr__("CONNECTION", value)
|
||||
super().__setattr__("PORT", value)
|
||||
return
|
||||
if name in {"PROTOCOL", "PROVIDER"}:
|
||||
super().__setattr__("PROTOCOL", value)
|
||||
super().__setattr__("PROVIDER", value)
|
||||
return
|
||||
super().__setattr__(name, value)
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import time
|
||||
from pubsub import pub
|
||||
|
||||
from . import config, handlers, ingestors, interfaces
|
||||
from .provider import Provider
|
||||
from .mesh_protocol import MeshProtocol
|
||||
from .utils import _retry_dict_snapshot
|
||||
|
||||
_RECEIVE_TOPICS = (
|
||||
@@ -245,7 +245,7 @@ def _connected_state(candidate) -> bool | None:
|
||||
class _DaemonState:
|
||||
"""All mutable state for the :func:`main` daemon loop."""
|
||||
|
||||
provider: Provider
|
||||
provider: MeshProtocol
|
||||
stop: threading.Event
|
||||
configured_port: str | None
|
||||
inactivity_reconnect_secs: float
|
||||
@@ -549,16 +549,16 @@ def _loop_iteration(state: _DaemonState) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(*, provider: Provider | None = None) -> None:
|
||||
def main(*, provider: MeshProtocol | None = None) -> None:
|
||||
"""Run the mesh ingestion daemon until interrupted."""
|
||||
|
||||
if provider is None:
|
||||
if config.PROVIDER == "meshcore":
|
||||
from .providers.meshcore import MeshcoreProvider
|
||||
if config.PROTOCOL == "meshcore":
|
||||
from .protocols.meshcore import MeshcoreProvider
|
||||
|
||||
provider = MeshcoreProvider()
|
||||
else:
|
||||
from .providers.meshtastic import MeshtasticProvider
|
||||
from .protocols.meshtastic import MeshtasticProvider
|
||||
|
||||
provider = MeshtasticProvider()
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ This package is organised into focused submodules:
|
||||
- :mod:`.generic` — packet dispatcher, node upsert, and the main receive callback
|
||||
|
||||
All public names from the original flat ``handlers`` module are re-exported
|
||||
here so existing callers (e.g. ``daemon.py``, ``providers/``) require no
|
||||
here so existing callers (e.g. ``daemon.py``, ``protocols/``) require no
|
||||
changes.
|
||||
"""
|
||||
|
||||
@@ -80,13 +80,13 @@ __all__ = [
|
||||
"_apply_radio_metadata",
|
||||
"_apply_radio_metadata_to_nodes",
|
||||
"_is_encrypted_flag",
|
||||
"_mark_packet_seen",
|
||||
"_normalize_trace_hops",
|
||||
"_portnum_candidates",
|
||||
"_queue_post_json",
|
||||
"_radio_metadata_fields",
|
||||
"_record_ignored_packet",
|
||||
"base64_payload",
|
||||
"_mark_packet_seen",
|
||||
"host_node_id",
|
||||
"last_packet_monotonic",
|
||||
"on_receive",
|
||||
|
||||
@@ -123,7 +123,7 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None:
|
||||
# Priority order matters: deviceMetrics is checked first because the device
|
||||
# sub-object also carries a voltage field that overlaps with powerMetrics.
|
||||
# Meshtastic uses a protobuf oneof so only one sub-object can be populated per
|
||||
# packet; the elif chain handles any hypothetical overlap from future providers.
|
||||
# packet; the elif chain handles any hypothetical overlap from future protocols.
|
||||
if isinstance(_dm, Mapping):
|
||||
telemetry_type: str | None = "device"
|
||||
elif isinstance(_em, Mapping):
|
||||
|
||||
@@ -113,7 +113,7 @@ def queue_ingestor_heartbeat(
|
||||
"start_time": STATE.start_time,
|
||||
"last_seen_time": now,
|
||||
"version": INGESTOR_VERSION,
|
||||
"protocol": getattr(config, "PROVIDER", "meshtastic") or "meshtastic",
|
||||
"protocol": getattr(config, "PROTOCOL", "meshtastic") or "meshtastic",
|
||||
}
|
||||
if getattr(config, "LORA_FREQ", None) is not None:
|
||||
payload["lora_freq"] = config.LORA_FREQ
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Provider interface for ingestion sources.
|
||||
"""MeshProtocol interface for ingestion sources.
|
||||
|
||||
Today the repo ships a Meshtastic provider only. This module defines the seam so
|
||||
future providers (MeshCore, Reticulum, ...) can be added without changing the
|
||||
web app ingest contract.
|
||||
This module defines the seam so future protocols (MeshCore, Reticulum, ...) can
|
||||
be added without changing the web app ingest contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,8 +25,8 @@ from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Provider(Protocol):
|
||||
"""Abstract source of mesh observations."""
|
||||
class MeshProtocol(Protocol):
|
||||
"""Abstract mesh protocol source."""
|
||||
|
||||
name: str
|
||||
|
||||
@@ -51,5 +50,8 @@ class Provider(Protocol):
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
"MeshProtocol",
|
||||
]
|
||||
|
||||
# Backwards-compatibility alias — import Provider from here during transition.
|
||||
Provider = MeshProtocol
|
||||
@@ -12,9 +12,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Provider implementations.
|
||||
"""Protocol implementations.
|
||||
|
||||
This package contains protocol-specific provider implementations (Meshtastic,
|
||||
This package contains protocol-specific implementations (Meshtastic,
|
||||
MeshCore, and others in the future).
|
||||
"""
|
||||
|
||||
@@ -24,11 +24,11 @@ from .meshtastic import MeshtasticProvider
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
"""Lazy-load provider classes and exceptions that carry optional heavy dependencies.
|
||||
"""Lazy-load protocol classes and exceptions that carry optional heavy dependencies.
|
||||
|
||||
``MeshcoreProvider`` and ``ClosedBeforeConnectedError`` are imported on
|
||||
demand so that the MeshCore library (once wired in) is not loaded at
|
||||
startup when ``PROVIDER=meshtastic``.
|
||||
startup when ``PROTOCOL=meshtastic``.
|
||||
"""
|
||||
if name == "MeshcoreProvider":
|
||||
from .meshcore import MeshcoreProvider
|
||||
+65
-12
@@ -12,17 +12,17 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""MeshCore provider implementation.
|
||||
"""MeshCore protocol implementation.
|
||||
|
||||
This module defines :class:`MeshcoreProvider`, which satisfies the
|
||||
:class:`~data.mesh_ingestor.provider.Provider` protocol for MeshCore nodes
|
||||
connected via serial port, BLE, or TCP/IP.
|
||||
:class:`~data.mesh_ingestor.mesh_protocol.MeshProtocol` interface for MeshCore
|
||||
nodes connected via serial port, BLE, or TCP/IP.
|
||||
|
||||
The provider runs MeshCore's ``asyncio`` event loop in a background daemon
|
||||
thread so that incoming events are dispatched without blocking the
|
||||
The protocol backend runs MeshCore's ``asyncio`` event loop in a background
|
||||
daemon thread so that incoming events are dispatched without blocking the
|
||||
synchronous daemon loop. Received contacts, channel messages, and direct
|
||||
messages are forwarded to the shared HTTP ingest queue via the same
|
||||
:mod:`~data.mesh_ingestor.handlers` helpers used by the Meshtastic provider.
|
||||
:mod:`~data.mesh_ingestor.handlers` helpers used by the Meshtastic protocol.
|
||||
|
||||
Connection type is detected automatically from the target string:
|
||||
|
||||
@@ -50,6 +50,25 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Import meshcore symbols at module level rather than lazily inside functions.
|
||||
# The original deferred-import pattern was introduced so that loading
|
||||
# ``protocols/__init__.py`` under ``PROTOCOL=meshtastic`` would not pull in the
|
||||
# meshcore library. That protection is preserved: ``protocols/__init__.py``
|
||||
# only imports THIS module on demand (via its ``__getattr__`` lazy loader), so
|
||||
# this top-level import still never executes for meshtastic-only deployments.
|
||||
# The import was hoisted because, after the rename from ``providers/meshcore``
|
||||
# to ``protocols/meshcore``, Python's absolute import resolver matched the
|
||||
# module's own short name (``meshcore``) against the installed package, causing
|
||||
# a ``ModuleNotFoundError`` when the deferred ``from meshcore import …`` ran
|
||||
# inside a background thread at connect time.
|
||||
from meshcore import (
|
||||
BLEConnection,
|
||||
EventType,
|
||||
MeshCore,
|
||||
SerialConnection,
|
||||
TCPConnection,
|
||||
)
|
||||
|
||||
from .. import config
|
||||
from ..connection import default_serial_targets, parse_ble_target, parse_tcp_target
|
||||
|
||||
@@ -234,6 +253,23 @@ def _contact_to_node_dict(contact: dict) -> dict:
|
||||
return node
|
||||
|
||||
|
||||
def _derive_modem_preset(sf: object, bw: object, cr: object) -> str | None:
|
||||
"""Return a compact radio-parameter string from spreading factor, bandwidth, and coding rate.
|
||||
|
||||
Parameters:
|
||||
sf: Spreading factor (int, e.g. ``12``).
|
||||
bw: Bandwidth in kHz (int or float, e.g. ``125.0``).
|
||||
cr: Coding rate denominator (int, e.g. ``5`` meaning 4/5).
|
||||
|
||||
Returns:
|
||||
A string such as ``"SF12/BW125/CR5"``, or ``None`` when any parameter
|
||||
is absent or zero (meaning the radio config was not reported).
|
||||
"""
|
||||
if not sf or not bw or not cr:
|
||||
return None
|
||||
return f"SF{int(sf)}/BW{int(bw)}/CR{int(cr)}"
|
||||
|
||||
|
||||
def _self_info_to_node_dict(self_info: dict) -> dict:
|
||||
"""Convert a MeshCore ``SELF_INFO`` payload to a Meshtastic-ish node dict.
|
||||
|
||||
@@ -414,11 +450,13 @@ class _MeshcoreInterface:
|
||||
def _process_self_info(
|
||||
payload: dict, iface: _MeshcoreInterface, handlers: object
|
||||
) -> None:
|
||||
"""Apply a ``SELF_INFO`` payload: set host_node_id and upsert the host node.
|
||||
"""Apply a ``SELF_INFO`` payload: set host_node_id, upsert the host node,
|
||||
and capture LoRa radio metadata into the shared config cache.
|
||||
|
||||
Parameters:
|
||||
payload: Event payload dict containing at minimum ``public_key`` and
|
||||
optionally ``name``, ``adv_lat``, ``adv_lon``.
|
||||
optionally ``name``, ``adv_lat``, ``adv_lon``, ``radio_freq``,
|
||||
``radio_bw``, ``radio_sf``, ``radio_cr``.
|
||||
iface: Active interface whose :attr:`host_node_id` will be updated.
|
||||
handlers: Module reference for :func:`~data.mesh_ingestor.handlers`
|
||||
functions (passed to avoid circular-import issues).
|
||||
@@ -429,6 +467,25 @@ def _process_self_info(
|
||||
iface.host_node_id = node_id
|
||||
handlers.register_host_node_id(node_id)
|
||||
handlers.upsert_node(node_id, _self_info_to_node_dict(payload))
|
||||
|
||||
# Capture radio metadata once — never overwrite a previously cached value.
|
||||
# Mirrors the guard used by interfaces._ensure_radio_metadata for Meshtastic.
|
||||
radio_freq = payload.get("radio_freq")
|
||||
if radio_freq is not None and getattr(config, "LORA_FREQ", None) is None:
|
||||
config.LORA_FREQ = radio_freq
|
||||
modem_preset = _derive_modem_preset(
|
||||
payload.get("radio_sf"), payload.get("radio_bw"), payload.get("radio_cr")
|
||||
)
|
||||
if modem_preset is not None and getattr(config, "MODEM_PRESET", None) is None:
|
||||
config.MODEM_PRESET = modem_preset
|
||||
config._debug_log(
|
||||
"MeshCore radio metadata captured",
|
||||
context="meshcore.self_info.radio",
|
||||
severity="info",
|
||||
lora_freq=radio_freq,
|
||||
modem_preset=modem_preset,
|
||||
)
|
||||
|
||||
handlers._mark_packet_seen()
|
||||
config._debug_log(
|
||||
"MeshCore self-info received",
|
||||
@@ -620,8 +677,6 @@ def _make_connection(target: str, baudrate: int) -> object:
|
||||
Returns:
|
||||
An unconnected ``meshcore`` connection object.
|
||||
"""
|
||||
from meshcore import BLEConnection, SerialConnection, TCPConnection
|
||||
|
||||
ble_addr = parse_ble_target(target)
|
||||
if ble_addr:
|
||||
return BLEConnection(address=ble_addr)
|
||||
@@ -655,8 +710,6 @@ async def _run_meshcore(
|
||||
error_holder: Single-element list; set to the raised exception when
|
||||
the connection attempt fails so the caller can re-raise it.
|
||||
"""
|
||||
from meshcore import EventType, MeshCore
|
||||
|
||||
# Install early so :meth:`_MeshcoreInterface.close` can signal shutdown with
|
||||
# ``stop_event.set()`` instead of ``loop.stop()`` while ``connect()`` or the
|
||||
# ``finally`` disconnect is still running (avoids RuntimeError from
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Meshtastic provider implementation."""
|
||||
"""Meshtastic protocol implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,7 +23,7 @@ from ..utils import _retry_dict_snapshot
|
||||
|
||||
|
||||
class MeshtasticProvider:
|
||||
"""Meshtastic ingestion provider (current default)."""
|
||||
"""Meshtastic ingestion protocol (current default)."""
|
||||
|
||||
name = "meshtastic"
|
||||
|
||||
+37
-10
@@ -196,30 +196,57 @@ class TestDebugLog:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PROVIDER validation
|
||||
# PROTOCOL validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderValidation:
|
||||
"""Tests for PROVIDER environment validation at import time."""
|
||||
class TestProtocolValidation:
|
||||
"""Tests for PROTOCOL environment validation at import time."""
|
||||
|
||||
def test_valid_provider_does_not_raise(self, monkeypatch):
|
||||
"""Importing config with a valid PROVIDER succeeds."""
|
||||
def test_valid_protocol_does_not_raise(self, monkeypatch):
|
||||
"""Importing config with a valid PROTOCOL succeeds."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("PROVIDER", "meshtastic")
|
||||
monkeypatch.setenv("PROTOCOL", "meshtastic")
|
||||
# Re-importing should not raise
|
||||
importlib.reload(config)
|
||||
|
||||
def test_invalid_provider_raises_value_error(self, monkeypatch):
|
||||
"""An invalid PROVIDER value raises ValueError at module load."""
|
||||
def test_invalid_protocol_raises_value_error(self, monkeypatch):
|
||||
"""An invalid PROTOCOL value raises ValueError at module load."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("PROVIDER", "bogus_provider_xyz")
|
||||
with pytest.raises(ValueError, match="Unknown PROVIDER"):
|
||||
monkeypatch.setenv("PROTOCOL", "bogus_protocol_xyz")
|
||||
with pytest.raises(ValueError, match="Unknown PROTOCOL"):
|
||||
importlib.reload(config)
|
||||
# Restore to valid value so subsequent tests work
|
||||
monkeypatch.setenv("PROTOCOL", "meshtastic")
|
||||
importlib.reload(config)
|
||||
|
||||
def test_protocol_env_var_takes_priority_over_provider(self, monkeypatch):
|
||||
"""PROTOCOL env var must take precedence over legacy PROVIDER."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("PROTOCOL", "meshcore")
|
||||
monkeypatch.setenv("PROVIDER", "meshtastic")
|
||||
cfg = importlib.reload(config)
|
||||
assert cfg.PROTOCOL == "meshcore"
|
||||
assert cfg.PROVIDER == "meshcore" # alias stays in sync
|
||||
# Restore
|
||||
monkeypatch.delenv("PROTOCOL")
|
||||
monkeypatch.delenv("PROVIDER")
|
||||
importlib.reload(config)
|
||||
|
||||
def test_provider_env_var_fallback(self, monkeypatch):
|
||||
"""Legacy PROVIDER env var must still be accepted when PROTOCOL is absent."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("PROTOCOL", raising=False)
|
||||
monkeypatch.setenv("PROVIDER", "meshcore")
|
||||
cfg = importlib.reload(config)
|
||||
assert cfg.PROTOCOL == "meshcore"
|
||||
assert cfg.PROVIDER == "meshcore"
|
||||
# Restore
|
||||
monkeypatch.delenv("PROVIDER")
|
||||
importlib.reload(config)
|
||||
|
||||
|
||||
|
||||
+27
-14
@@ -828,7 +828,7 @@ def test_loop_iteration_full_pass_returns_false(monkeypatch):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PROVIDER env-var selection
|
||||
# PROTOCOL env-var selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -892,10 +892,11 @@ def _reload_config() -> types.ModuleType:
|
||||
|
||||
@pytest.fixture()
|
||||
def reset_provider_config():
|
||||
"""Reload config after the test so PROVIDER changes don't leak across tests."""
|
||||
"""Reload config after the test so PROTOCOL/PROVIDER changes don't leak across tests."""
|
||||
yield
|
||||
import os
|
||||
|
||||
os.environ.pop("PROTOCOL", None)
|
||||
os.environ.pop("PROVIDER", None)
|
||||
_reload_config()
|
||||
|
||||
@@ -907,33 +908,45 @@ def reset_provider_config():
|
||||
("meshcore", "meshcore"),
|
||||
],
|
||||
)
|
||||
def test_config_provider_env(monkeypatch, reset_provider_config, env_value, expected):
|
||||
"""PROVIDER env var selects the provider; absent defaults to 'meshtastic'."""
|
||||
def test_config_protocol_env(monkeypatch, reset_provider_config, env_value, expected):
|
||||
"""PROTOCOL env var selects the protocol; absent defaults to 'meshtastic'."""
|
||||
if env_value is None:
|
||||
monkeypatch.delenv("PROTOCOL", raising=False)
|
||||
monkeypatch.delenv("PROVIDER", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("PROVIDER", env_value)
|
||||
assert _reload_config().PROVIDER == expected
|
||||
monkeypatch.setenv("PROTOCOL", env_value)
|
||||
cfg = _reload_config()
|
||||
assert cfg.PROTOCOL == expected
|
||||
assert cfg.PROVIDER == expected # deprecated alias stays in sync
|
||||
|
||||
|
||||
def test_config_provider_unknown_raises(monkeypatch, reset_provider_config):
|
||||
"""An unrecognised PROVIDER value must raise ValueError at import time."""
|
||||
monkeypatch.setenv("PROVIDER", "reticulum")
|
||||
with pytest.raises(ValueError, match="PROVIDER"):
|
||||
def test_config_provider_env_fallback(monkeypatch, reset_provider_config):
|
||||
"""Legacy PROVIDER env var must still work when PROTOCOL is absent."""
|
||||
monkeypatch.delenv("PROTOCOL", raising=False)
|
||||
monkeypatch.setenv("PROVIDER", "meshcore")
|
||||
cfg = _reload_config()
|
||||
assert cfg.PROTOCOL == "meshcore"
|
||||
assert cfg.PROVIDER == "meshcore"
|
||||
|
||||
|
||||
def test_config_protocol_unknown_raises(monkeypatch, reset_provider_config):
|
||||
"""An unrecognised PROTOCOL value must raise ValueError at import time."""
|
||||
monkeypatch.setenv("PROTOCOL", "reticulum")
|
||||
with pytest.raises(ValueError, match="PROTOCOL"):
|
||||
_reload_config()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_name, module_path, class_name",
|
||||
[
|
||||
("meshtastic", "data.mesh_ingestor.providers.meshtastic", "MeshtasticProvider"),
|
||||
("meshcore", "data.mesh_ingestor.providers.meshcore", "MeshcoreProvider"),
|
||||
("meshtastic", "data.mesh_ingestor.protocols.meshtastic", "MeshtasticProvider"),
|
||||
("meshcore", "data.mesh_ingestor.protocols.meshcore", "MeshcoreProvider"),
|
||||
],
|
||||
)
|
||||
def test_daemon_main_selects_provider(
|
||||
monkeypatch, provider_name, module_path, class_name
|
||||
):
|
||||
"""main() must instantiate the correct provider class based on PROVIDER."""
|
||||
"""main() must instantiate the correct protocol class based on PROTOCOL."""
|
||||
mod = importlib.import_module(module_path)
|
||||
instantiated = []
|
||||
|
||||
@@ -943,7 +956,7 @@ def test_daemon_main_selects_provider(
|
||||
return p
|
||||
|
||||
_patch_daemon_for_fast_exit(monkeypatch)
|
||||
monkeypatch.setattr(daemon.config, "PROVIDER", provider_name)
|
||||
monkeypatch.setattr(daemon.config, "PROTOCOL", provider_name)
|
||||
monkeypatch.setattr(mod, class_name, make_provider)
|
||||
|
||||
daemon.main()
|
||||
|
||||
+1
-1
@@ -3215,7 +3215,7 @@ def test_queue_ingestor_heartbeat_protocol_meshcore(mesh_module, monkeypatch):
|
||||
|
||||
mesh.ingestors.STATE.last_heartbeat = None
|
||||
mesh.ingestors.STATE.node_id = None
|
||||
mesh.config.PROVIDER = "meshcore"
|
||||
mesh.config.PROTOCOL = "meshcore"
|
||||
|
||||
mesh.ingestors.set_ingestor_node_id("!aabbccdd")
|
||||
mesh.ingestors.queue_ingestor_heartbeat(force=True)
|
||||
|
||||
+204
-62
@@ -27,16 +27,17 @@ 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
|
||||
from data.mesh_ingestor.mesh_protocol import MeshProtocol # noqa: E402 - path setup
|
||||
from data.mesh_ingestor.protocols.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
|
||||
from data.mesh_ingestor.protocols.meshcore import ( # noqa: E402 - path setup
|
||||
MeshcoreProvider,
|
||||
_MeshcoreInterface,
|
||||
_contact_to_node_dict,
|
||||
_derive_message_id,
|
||||
_derive_modem_preset,
|
||||
_make_connection,
|
||||
_make_event_handlers,
|
||||
_meshcore_adv_type_to_role,
|
||||
@@ -54,7 +55,7 @@ from data.mesh_ingestor.providers.meshcore import ( # noqa: E402 - path setup
|
||||
|
||||
def test_meshtastic_provider_satisfies_protocol():
|
||||
"""MeshtasticProvider must structurally satisfy the Provider Protocol."""
|
||||
assert isinstance(MeshtasticProvider(), Provider)
|
||||
assert isinstance(MeshtasticProvider(), MeshProtocol)
|
||||
|
||||
|
||||
def test_daemon_main_uses_provider_connect(monkeypatch):
|
||||
@@ -135,7 +136,7 @@ def test_daemon_main_uses_provider_connect(monkeypatch):
|
||||
|
||||
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
|
||||
from data.mesh_ingestor.protocols.meshtastic import MeshtasticProvider
|
||||
|
||||
attempt = {"n": 0}
|
||||
|
||||
@@ -157,8 +158,8 @@ def test_node_snapshot_items_retries_on_concurrent_mutation(monkeypatch):
|
||||
|
||||
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
|
||||
from data.mesh_ingestor.protocols.meshtastic import MeshtasticProvider
|
||||
import data.mesh_ingestor.protocols.meshtastic as _mod
|
||||
|
||||
class AlwaysMutating:
|
||||
def items(self):
|
||||
@@ -175,7 +176,7 @@ def test_node_snapshot_items_returns_empty_after_retry_exhaustion(monkeypatch):
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshtastic as _m
|
||||
|
||||
subscribe_calls: list[str] = []
|
||||
|
||||
@@ -203,7 +204,7 @@ def test_meshtastic_subscribe_is_idempotent(monkeypatch):
|
||||
|
||||
def test_meshcore_provider_satisfies_protocol():
|
||||
"""MeshcoreProvider must structurally satisfy the Provider Protocol."""
|
||||
assert isinstance(MeshcoreProvider(), Provider)
|
||||
assert isinstance(MeshcoreProvider(), MeshProtocol)
|
||||
|
||||
|
||||
def test_meshcore_provider_name():
|
||||
@@ -227,7 +228,7 @@ def test_meshcore_subscribe_returns_empty_list():
|
||||
)
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
||||
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
||||
@@ -277,7 +278,7 @@ def _fake_run_meshcore(*, error=None, host_node_id=None):
|
||||
)
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
||||
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
||||
@@ -293,7 +294,7 @@ def test_meshcore_connect_accepts_serial_ble_targets(target, monkeypatch):
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
||||
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
||||
@@ -329,7 +330,7 @@ def test_make_connection_routes_to_correct_class(
|
||||
):
|
||||
"""_make_connection must instantiate the correct meshcore connection class."""
|
||||
import types
|
||||
import data.mesh_ingestor.providers.meshcore as _mod
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
instances: list = []
|
||||
|
||||
@@ -342,22 +343,14 @@ def test_make_connection_routes_to_correct_class(
|
||||
_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")
|
||||
# Patch module-level names directly — sys.modules patching no longer works
|
||||
# because BLEConnection/SerialConnection/TCPConnection are imported at module
|
||||
# load time (not lazily inside the function).
|
||||
monkeypatch.setattr(_mod, "BLEConnection", _make_mock("BLEConnection"))
|
||||
monkeypatch.setattr(_mod, "SerialConnection", _make_mock("SerialConnection"))
|
||||
monkeypatch.setattr(_mod, "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
|
||||
result = _make_connection(target, 115200)
|
||||
|
||||
assert len(instances) == 1
|
||||
assert instances[0].name == expected_class_name
|
||||
@@ -365,7 +358,7 @@ def test_make_connection_routes_to_correct_class(
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
||||
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
||||
@@ -377,7 +370,7 @@ def test_meshcore_connect_returns_closeable_interface(monkeypatch):
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
||||
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
||||
@@ -389,7 +382,7 @@ def test_meshcore_extract_host_node_id_none_by_default(monkeypatch):
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
_mod, "_run_meshcore", _fake_run_meshcore(host_node_id="!aabbccdd")
|
||||
@@ -403,7 +396,7 @@ def test_meshcore_extract_host_node_id_set_on_connect(monkeypatch):
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
exc = ConnectionError("no response")
|
||||
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore(error=exc))
|
||||
@@ -420,7 +413,7 @@ def test_meshcore_node_snapshot_items_non_interface():
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore())
|
||||
monkeypatch.setattr(_mod.config, "CONNECTION", None)
|
||||
@@ -458,7 +451,7 @@ def test_parse_tcp_target_rejects_serial_ble():
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "DEBUG", False)
|
||||
log_path = tmp_path / "ignored-meshcore.txt"
|
||||
@@ -472,7 +465,7 @@ def test_record_meshcore_message_skipped_without_debug(monkeypatch, tmp_path):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "DEBUG", True)
|
||||
log_path = tmp_path / "ignored-meshcore.txt"
|
||||
@@ -491,7 +484,7 @@ def test_record_meshcore_message_writes_with_debug(monkeypatch, tmp_path):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "DEBUG", True)
|
||||
log_path = tmp_path / "ignored-meshcore.txt"
|
||||
@@ -506,7 +499,7 @@ def test_record_meshcore_message_serialises_bytes(monkeypatch, tmp_path):
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "DEBUG", True)
|
||||
log_path = tmp_path / "ignored-meshcore.txt"
|
||||
@@ -837,7 +830,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
captured: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
@@ -883,7 +876,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
captured: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
@@ -928,7 +921,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
captured: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
@@ -957,7 +950,7 @@ def test_connect_raises_on_timeout(monkeypatch):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
async def _hanging(iface, target, connected_event, error_holder):
|
||||
# Never signals connected_event — simulates a device that does not respond.
|
||||
@@ -1056,6 +1049,136 @@ def test_process_self_info_skips_empty_key():
|
||||
assert registered == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _process_self_info — radio metadata capture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_process_self_info_captures_radio_freq(monkeypatch):
|
||||
"""SELF_INFO with radio_freq must populate config.LORA_FREQ."""
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
|
||||
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
stub = _make_stub_handlers_module()
|
||||
payload = {
|
||||
"public_key": "aabbccdd" + "00" * 28,
|
||||
"radio_freq": 868.125,
|
||||
"radio_sf": 12,
|
||||
"radio_bw": 125.0,
|
||||
"radio_cr": 5,
|
||||
}
|
||||
_process_self_info(payload, _MeshcoreInterface(target=None), stub)
|
||||
|
||||
assert _mod.config.LORA_FREQ == pytest.approx(868.125)
|
||||
|
||||
|
||||
def test_process_self_info_captures_modem_preset(monkeypatch):
|
||||
"""SELF_INFO with radio_sf, radio_bw, radio_cr must populate config.MODEM_PRESET."""
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
|
||||
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
stub = _make_stub_handlers_module()
|
||||
payload = {
|
||||
"public_key": "aabbccdd" + "00" * 28,
|
||||
"radio_freq": 868.125,
|
||||
"radio_sf": 12,
|
||||
"radio_bw": 125.0,
|
||||
"radio_cr": 5,
|
||||
}
|
||||
_process_self_info(payload, _MeshcoreInterface(target=None), stub)
|
||||
|
||||
assert _mod.config.MODEM_PRESET == "SF12/BW125/CR5"
|
||||
|
||||
|
||||
def test_process_self_info_no_overwrite_lora_freq(monkeypatch):
|
||||
"""SELF_INFO must not overwrite an already-cached LORA_FREQ."""
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "LORA_FREQ", 915)
|
||||
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
stub = _make_stub_handlers_module()
|
||||
payload = {
|
||||
"public_key": "aabbccdd" + "00" * 28,
|
||||
"radio_freq": 868.125,
|
||||
"radio_sf": 12,
|
||||
"radio_bw": 125.0,
|
||||
"radio_cr": 5,
|
||||
}
|
||||
_process_self_info(payload, _MeshcoreInterface(target=None), stub)
|
||||
|
||||
assert _mod.config.LORA_FREQ == 915
|
||||
|
||||
|
||||
def test_process_self_info_no_overwrite_modem_preset(monkeypatch):
|
||||
"""SELF_INFO must not overwrite an already-cached MODEM_PRESET."""
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
|
||||
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "LongFast")
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
stub = _make_stub_handlers_module()
|
||||
payload = {
|
||||
"public_key": "aabbccdd" + "00" * 28,
|
||||
"radio_freq": 868.125,
|
||||
"radio_sf": 12,
|
||||
"radio_bw": 125.0,
|
||||
"radio_cr": 5,
|
||||
}
|
||||
_process_self_info(payload, _MeshcoreInterface(target=None), stub)
|
||||
|
||||
assert _mod.config.MODEM_PRESET == "LongFast"
|
||||
|
||||
|
||||
def test_process_self_info_missing_radio_fields_leaves_config_none(monkeypatch):
|
||||
"""SELF_INFO with no radio_* keys must leave LORA_FREQ and MODEM_PRESET as None."""
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
|
||||
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
stub = _make_stub_handlers_module()
|
||||
_process_self_info(
|
||||
{"public_key": "aabbccdd" + "00" * 28, "name": "Node"},
|
||||
_MeshcoreInterface(target=None),
|
||||
stub,
|
||||
)
|
||||
|
||||
assert _mod.config.LORA_FREQ is None
|
||||
assert _mod.config.MODEM_PRESET is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _derive_modem_preset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_derive_modem_preset_valid():
|
||||
"""_derive_modem_preset must format SF, BW, and CR into a compact string."""
|
||||
assert _derive_modem_preset(12, 125.0, 5) == "SF12/BW125/CR5"
|
||||
assert _derive_modem_preset(7, 250.0, 5) == "SF7/BW250/CR5"
|
||||
assert _derive_modem_preset(11, 62.5, 8) == "SF11/BW62/CR8"
|
||||
|
||||
|
||||
def test_derive_modem_preset_none_on_missing():
|
||||
"""_derive_modem_preset must return None when any parameter is absent or zero."""
|
||||
assert _derive_modem_preset(None, 125.0, 5) is None
|
||||
assert _derive_modem_preset(12, None, 5) is None
|
||||
assert _derive_modem_preset(12, 125.0, None) is None
|
||||
assert _derive_modem_preset(0, 125.0, 5) is None
|
||||
assert _derive_modem_preset(12, 0, 5) is None
|
||||
assert _derive_modem_preset(12, 125.0, 0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _process_contacts
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1107,7 +1230,7 @@ def test_process_contacts_marks_packet_seen():
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
@@ -1125,7 +1248,7 @@ def test_process_contact_update_upserts_node(monkeypatch):
|
||||
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
@@ -1149,7 +1272,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
registered: list = []
|
||||
upserted: list = []
|
||||
@@ -1180,7 +1303,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
upserted: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
@@ -1209,7 +1332,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
upserted: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
@@ -1238,7 +1361,7 @@ def test_on_new_contact_and_next_contact_update_iface(monkeypatch):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
|
||||
@@ -1262,7 +1385,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
captured: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
@@ -1285,7 +1408,7 @@ 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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
captured: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
@@ -1311,10 +1434,29 @@ def test_on_contact_msg_includes_protocol_meshcore(monkeypatch):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_meshcore — full coroutine paths (fake meshcore module in sys.modules)
|
||||
# _run_meshcore — full coroutine paths (fake meshcore module patched on the module)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _patch_meshcore_mod(monkeypatch, mod, fake_mod):
|
||||
"""Patch module-level meshcore names in *mod* with values from *fake_mod*.
|
||||
|
||||
Since ``protocols/meshcore.py`` now imports ``BLEConnection``, ``EventType``,
|
||||
``MeshCore``, ``SerialConnection``, and ``TCPConnection`` at module load time
|
||||
(not lazily inside functions), ``sys.modules`` patching no longer reaches
|
||||
these already-bound names. This helper patches the module attributes
|
||||
directly so tests can substitute fakes at the point of use.
|
||||
"""
|
||||
for attr in (
|
||||
"BLEConnection",
|
||||
"EventType",
|
||||
"MeshCore",
|
||||
"SerialConnection",
|
||||
"TCPConnection",
|
||||
):
|
||||
monkeypatch.setattr(mod, attr, getattr(fake_mod, attr))
|
||||
|
||||
|
||||
def _make_fake_meshcore_mod(
|
||||
*,
|
||||
connect_result: object = "ok",
|
||||
@@ -1436,12 +1578,12 @@ def _setup_stalled_run(monkeypatch):
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import data.mesh_ingestor.providers.meshcore as _mod
|
||||
import data.mesh_ingestor.protocols.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)
|
||||
_patch_meshcore_mod(monkeypatch, _mod, fake_mod)
|
||||
return stall, _mod
|
||||
|
||||
|
||||
@@ -1527,11 +1669,11 @@ def test_run_meshcore_close_before_connect_completes(monkeypatch):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.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)
|
||||
_patch_meshcore_mod(monkeypatch, _mod, fake_mod)
|
||||
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
|
||||
@@ -1548,11 +1690,11 @@ 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
|
||||
import data.mesh_ingestor.protocols.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)
|
||||
_patch_meshcore_mod(monkeypatch, _mod, fake_mod)
|
||||
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
connected_event = threading.Event()
|
||||
@@ -1570,7 +1712,7 @@ def test_run_meshcore_connect_returns_none_raises(monkeypatch):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
logged: list = []
|
||||
monkeypatch.setattr(
|
||||
@@ -1579,7 +1721,7 @@ def test_run_meshcore_ensure_contacts_failure_continues(monkeypatch):
|
||||
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)
|
||||
_patch_meshcore_mod(monkeypatch, _mod, fake_mod)
|
||||
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
|
||||
@@ -1595,11 +1737,11 @@ def test_run_meshcore_ensure_contacts_failure_continues(monkeypatch):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.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)
|
||||
_patch_meshcore_mod(monkeypatch, _mod, fake_mod)
|
||||
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
|
||||
@@ -1614,7 +1756,7 @@ def test_run_meshcore_disconnect_exception_suppressed(monkeypatch):
|
||||
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
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
recorded: list = []
|
||||
@@ -1624,7 +1766,7 @@ def test_run_meshcore_on_unhandled_skips_known_records_unknown(monkeypatch):
|
||||
lambda msg, *, source: recorded.append(source),
|
||||
)
|
||||
fake_mod = _make_fake_meshcore_mod()
|
||||
monkeypatch.setitem(sys.modules, "meshcore", fake_mod)
|
||||
_patch_meshcore_mod(monkeypatch, _mod, fake_mod)
|
||||
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user