diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 65becc1..3db3aab 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index f680047..1485cbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.py` with a class satisfying the Protocol. -2. Register it in `data/mesh_ingestor/providers/__init__.py`. +1. Create `data/mesh_ingestor/protocols/.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. diff --git a/data/mesh.sh b/data/mesh.sh index 451a577..abc5906 100755 --- a/data/mesh.sh +++ b/data/mesh.sh @@ -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 diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index f362dce..2afc565 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -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 diff --git a/data/mesh_ingestor/config.py b/data/mesh_ingestor/config.py index b40997a..ea420e5 100644 --- a/data/mesh_ingestor/config.py +++ b/data/mesh_ingestor/config.py @@ -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) diff --git a/data/mesh_ingestor/daemon.py b/data/mesh_ingestor/daemon.py index f7e5d4e..bb1785d 100644 --- a/data/mesh_ingestor/daemon.py +++ b/data/mesh_ingestor/daemon.py @@ -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() diff --git a/data/mesh_ingestor/handlers/__init__.py b/data/mesh_ingestor/handlers/__init__.py index 9cae0ca..1d0b7fd 100644 --- a/data/mesh_ingestor/handlers/__init__.py +++ b/data/mesh_ingestor/handlers/__init__.py @@ -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", diff --git a/data/mesh_ingestor/handlers/telemetry.py b/data/mesh_ingestor/handlers/telemetry.py index 18a4ed3..4a0aff2 100644 --- a/data/mesh_ingestor/handlers/telemetry.py +++ b/data/mesh_ingestor/handlers/telemetry.py @@ -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): diff --git a/data/mesh_ingestor/ingestors.py b/data/mesh_ingestor/ingestors.py index 2143723..9516a5a 100644 --- a/data/mesh_ingestor/ingestors.py +++ b/data/mesh_ingestor/ingestors.py @@ -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 diff --git a/data/mesh_ingestor/provider.py b/data/mesh_ingestor/mesh_protocol.py similarity index 78% rename from data/mesh_ingestor/provider.py rename to data/mesh_ingestor/mesh_protocol.py index e2f5d2a..5c42be3 100644 --- a/data/mesh_ingestor/provider.py +++ b/data/mesh_ingestor/mesh_protocol.py @@ -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 diff --git a/data/mesh_ingestor/providers/__init__.py b/data/mesh_ingestor/protocols/__init__.py similarity index 86% rename from data/mesh_ingestor/providers/__init__.py rename to data/mesh_ingestor/protocols/__init__.py index 92b6c98..06f3841 100644 --- a/data/mesh_ingestor/providers/__init__.py +++ b/data/mesh_ingestor/protocols/__init__.py @@ -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 diff --git a/data/mesh_ingestor/providers/meshcore.py b/data/mesh_ingestor/protocols/meshcore.py similarity index 91% rename from data/mesh_ingestor/providers/meshcore.py rename to data/mesh_ingestor/protocols/meshcore.py index 86dc515..e24e8ed 100644 --- a/data/mesh_ingestor/providers/meshcore.py +++ b/data/mesh_ingestor/protocols/meshcore.py @@ -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 diff --git a/data/mesh_ingestor/providers/meshtastic.py b/data/mesh_ingestor/protocols/meshtastic.py similarity index 96% rename from data/mesh_ingestor/providers/meshtastic.py rename to data/mesh_ingestor/protocols/meshtastic.py index d92b2aa..afd75c9 100644 --- a/data/mesh_ingestor/providers/meshtastic.py +++ b/data/mesh_ingestor/protocols/meshtastic.py @@ -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" diff --git a/tests/test_config_unit.py b/tests/test_config_unit.py index c79ede4..d2a0d64 100644 --- a/tests/test_config_unit.py +++ b/tests/test_config_unit.py @@ -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) diff --git a/tests/test_daemon_unit.py b/tests/test_daemon_unit.py index 307b1ca..b9adc46 100644 --- a/tests/test_daemon_unit.py +++ b/tests/test_daemon_unit.py @@ -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() diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 81ab48d..eec0433 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -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) diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py index a57a875..1bde298 100644 --- a/tests/test_provider_unit.py +++ b/tests/test_provider_unit.py @@ -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)