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:
l5y
2026-04-05 09:13:48 +02:00
committed by GitHub
parent 06530f36ff
commit d33ebd8f4c
17 changed files with 402 additions and 145 deletions
+1 -1
View File
@@ -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
+7 -7
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -5,7 +5,7 @@ This repos 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
+22 -8
View File
@@ -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)
+6 -6
View File
@@ -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()
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)