diff --git a/data/mesh_ingestor/__init__.py b/data/mesh_ingestor/__init__.py index 9f7c68a..4b5ac8d 100644 --- a/data/mesh_ingestor/__init__.py +++ b/data/mesh_ingestor/__init__.py @@ -25,6 +25,7 @@ from .. import VERSION as _PACKAGE_VERSION from . import ( channels, config, + connection, daemon, handlers, ingestors, @@ -46,7 +47,7 @@ def _reexport(module) -> None: def _export_constants() -> None: globals()["json"] = queue.json globals()["urllib"] = queue.urllib - globals()["glob"] = interfaces.glob + globals()["glob"] = connection.glob __all__.extend(["json", "urllib", "glob", "threading", "signal"]) diff --git a/data/mesh_ingestor/connection.py b/data/mesh_ingestor/connection.py new file mode 100644 index 0000000..94d68c9 --- /dev/null +++ b/data/mesh_ingestor/connection.py @@ -0,0 +1,163 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Provider-agnostic connection target helpers. + +This module contains utilities shared by all ingestor providers for +parsing and auto-discovering connection targets. It is intentionally +free of any provider-specific imports so that Meshtastic, MeshCore, +and future providers can all rely on the same logic. +""" + +from __future__ import annotations + +import glob +import re + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_TCP_PORT: int = 4403 +"""Default TCP port used when no port is explicitly supplied.""" + +DEFAULT_SERIAL_PATTERNS: tuple[str, ...] = ( + "/dev/ttyACM*", + "/dev/ttyUSB*", + "/dev/tty.usbmodem*", + "/dev/tty.usbserial*", + "/dev/cu.usbmodem*", + "/dev/cu.usbserial*", +) +"""Glob patterns for common serial device paths on Linux and macOS.""" + +# Support both MAC addresses (Linux/Windows) and UUIDs (macOS). +BLE_ADDRESS_RE = re.compile( + r"^(?:" + r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}|" # MAC address format + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" # UUID format + r")$" +) +"""Compiled regex matching a BLE MAC address or UUID.""" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def parse_ble_target(value: str) -> str | None: + """Return a normalised BLE address (MAC or UUID) when ``value`` matches the format. + + Parameters: + value: User-provided target string. + + Returns: + The normalised MAC address (upper-cased) or UUID, or ``None`` when + the value does not match a recognised BLE address format. + """ + if not value: + return None + value = value.strip() + if not value: + return None + if BLE_ADDRESS_RE.fullmatch(value): + return value.upper() + return None + + +def parse_tcp_target(value: str) -> tuple[str, int] | None: + """Parse a TCP ``host:port`` target, accepting both IPs and hostnames. + + Unlike the Meshtastic-specific helper in :mod:`interfaces`, hostnames are + accepted here because MeshCore companions may be reached over a local + network by name (e.g. ``meshcore-node.local:4403``). + + BLE MAC addresses (five colons) and bare serial port paths (no colon) are + correctly rejected — they cannot produce a valid ``host:port`` pair. + + Parameters: + value: User-provided target string. + + Returns: + ``(host, port)`` on success, or ``None`` when *value* does not look + like a TCP target. + """ + if not value: + return None + value = value.strip() + if not value: + return None + + # Strip URL scheme prefix (e.g. ``tcp://host:4403`` or ``http://host:4403``). + if "://" in value: + value = value.split("://", 1)[1] + + # Handle bracketed IPv6: ``[::1]:4403``. + if value.startswith("["): + bracket_end = value.find("]") + if bracket_end == -1: + return None + host = value[1:bracket_end] + rest = value[bracket_end + 1 :] + if rest.startswith(":"): + try: + port = int(rest[1:]) + except ValueError: + return None + if not (1 <= port <= 65535): + return None + else: + port = DEFAULT_TCP_PORT + if not host: + return None + return host, port + + # For non-bracketed addresses require exactly one colon so that BLE MACs + # (five colons) and bare serial paths (no colon) are rejected. + colon_count = value.count(":") + if colon_count != 1: + return None + + host, _, port_str = value.partition(":") + if not host: + return None + try: + port = int(port_str) + except ValueError: + return None + if not (1 <= port <= 65535): + return None + return host, port + + +def default_serial_targets() -> list[str]: + """Return candidate serial device paths for auto-discovery. + + Globs for common USB serial device paths on Linux and macOS. Always + includes ``/dev/ttyACM0`` as a final fallback so callers have at least + one candidate even on systems without any attached hardware. + + Returns: + Ordered list of candidate device paths, deduplicated. + """ + candidates: list[str] = [] + seen: set[str] = set() + for pattern in DEFAULT_SERIAL_PATTERNS: + for path in sorted(glob.glob(pattern)): + if path not in seen: + candidates.append(path) + seen.add(path) + if "/dev/ttyACM0" not in seen: + candidates.append("/dev/ttyACM0") + return candidates diff --git a/data/mesh_ingestor/interfaces.py b/data/mesh_ingestor/interfaces.py index 128611f..70b084e 100644 --- a/data/mesh_ingestor/interfaces.py +++ b/data/mesh_ingestor/interfaces.py @@ -17,7 +17,6 @@ from __future__ import annotations import contextlib -import glob import importlib import ipaddress import math @@ -33,6 +32,13 @@ except Exception: # pragma: no cover - dependency optional in tests meshtastic = None # type: ignore[assignment] from . import channels, config, serialization +from .connection import ( + BLE_ADDRESS_RE, + DEFAULT_TCP_PORT, + DEFAULT_SERIAL_PATTERNS, + default_serial_targets, + parse_ble_target, +) def _ensure_mapping(value) -> Mapping | None: @@ -616,25 +622,13 @@ def _ensure_channel_metadata(iface: Any) -> None: ) -_DEFAULT_TCP_PORT = 4403 _DEFAULT_TCP_TARGET = "http://127.0.0.1" -_DEFAULT_SERIAL_PATTERNS = ( - "/dev/ttyACM*", - "/dev/ttyUSB*", - "/dev/tty.usbmodem*", - "/dev/tty.usbserial*", - "/dev/cu.usbmodem*", - "/dev/cu.usbserial*", -) - -# Support both MAC addresses (Linux/Windows) and UUIDs (macOS) -_BLE_ADDRESS_RE = re.compile( - r"^(?:" - r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}|" # MAC address format - r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" # UUID format - r")$" -) +# Private aliases so that existing internal callers and monkeypatching in +# tests keep working without modification. +_DEFAULT_TCP_PORT = DEFAULT_TCP_PORT # backward-compat alias +_DEFAULT_SERIAL_PATTERNS = DEFAULT_SERIAL_PATTERNS # backward-compat alias +_BLE_ADDRESS_RE = BLE_ADDRESS_RE # backward-compat alias class _DummySerialInterface: @@ -647,24 +641,7 @@ class _DummySerialInterface: pass -def _parse_ble_target(value: str) -> str | None: - """Return a normalized BLE address (MAC or UUID) when ``value`` matches the format. - - Parameters: - value: User-provided target string. - - Returns: - The normalised MAC address or UUID, or ``None`` when validation fails. - """ - - if not value: - return None - value = value.strip() - if not value: - return None - if _BLE_ADDRESS_RE.fullmatch(value): - return value.upper() - return None +_parse_ble_target = parse_ble_target # backward-compat alias def _parse_network_target(value: str) -> tuple[str, int] | None: @@ -812,19 +789,7 @@ class NoAvailableMeshInterface(RuntimeError): """Raised when no default mesh interface can be created.""" -def _default_serial_targets() -> list[str]: - """Return candidate serial device paths for auto-discovery.""" - - candidates: list[str] = [] - seen: set[str] = set() - for pattern in _DEFAULT_SERIAL_PATTERNS: - for path in sorted(glob.glob(pattern)): - if path not in seen: - candidates.append(path) - seen.add(path) - if "/dev/ttyACM0" not in seen: - candidates.append("/dev/ttyACM0") - return candidates +_default_serial_targets = default_serial_targets # backward-compat alias def _create_default_interface() -> tuple[object, str]: diff --git a/data/mesh_ingestor/providers/meshcore.py b/data/mesh_ingestor/providers/meshcore.py index 4e5aa2a..3779e55 100644 --- a/data/mesh_ingestor/providers/meshcore.py +++ b/data/mesh_ingestor/providers/meshcore.py @@ -16,8 +16,7 @@ This module defines :class:`MeshcoreProvider`, which satisfies the :class:`~data.mesh_ingestor.provider.Provider` protocol for MeshCore nodes -connected via serial port or BLE. TCP/IP targets are not supported by -MeshCore and will be rejected at connect time. +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 @@ -25,6 +24,14 @@ 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. +Connection type is detected automatically from the target string: + +* **BLE** — MAC address (``AA:BB:CC:DD:EE:FF``) or UUID (macOS format). +* **TCP** — ``host:port`` or ``[ipv6]:port`` (accepts hostnames). +* **Serial** — any other non-empty string (e.g. ``/dev/ttyUSB0``). +* **Auto** — ``None`` or empty: tries serial candidates from + :func:`~data.mesh_ingestor.connection.default_serial_targets`. + Node identities are derived from the first four bytes (eight hex characters) of each contact's 32-byte public key, formatted as ``!xxxxxxxx`` to match the canonical node-ID schema used across the system. @@ -36,13 +43,13 @@ import asyncio import base64 import hashlib import json -import re import threading import time from datetime import datetime, timezone from pathlib import Path from .. import config +from ..connection import default_serial_targets, parse_ble_target, parse_tcp_target # --------------------------------------------------------------------------- # Debug log file @@ -68,14 +75,6 @@ _DEFAULT_BAUDRATE: int = 115200 # Helpers # --------------------------------------------------------------------------- -_TCP_TARGET_RE = re.compile(r"[^:]+:\d{1,5}") -"""Pattern matching a ``host:port`` TCP target (exactly one colon, port 1–5 digits). - -Using ``fullmatch`` ensures BLE MAC addresses (``AA:BB:CC:DD:EE:12``) are not -mistaken for TCP targets — the multiple colons in a MAC prevent a full match -against the ``[^:]+:\\d{1,5}`` pattern. -""" - def _derive_message_id(sender_ts: int, discriminator: str, text: str) -> int: """Derive a stable 32-bit message ID from available MeshCore fields. @@ -100,16 +99,6 @@ def _derive_message_id(sender_ts: int, discriminator: str, text: str) -> int: return int.from_bytes(hashlib.sha256(data).digest()[:4], "big") -def _is_tcp_target(target: str) -> bool: - """Return ``True`` when *target* looks like a TCP ``host:port`` address. - - BLE MAC addresses such as ``AA:BB:CC:DD:EE:12`` are correctly rejected - because they contain more than one colon, which prevents a full match - against the ``[^:]+:\\d{1,5}`` pattern. - """ - return bool(_TCP_TARGET_RE.fullmatch(target)) - - def _meshcore_node_id(public_key_hex: str | None) -> str | None: """Derive a canonical ``!xxxxxxxx`` node ID from a MeshCore public key. @@ -540,9 +529,40 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict: # --------------------------------------------------------------------------- +def _make_connection(target: str, baudrate: int) -> object: + """Create the appropriate MeshCore connection object for *target*. + + Routes to the correct ``meshcore`` connection class based on the target + string format: + + * BLE MAC / UUID → :class:`meshcore.BLEConnection` + * ``host:port`` / ``[ipv6]:port`` → :class:`meshcore.TCPConnection` + * anything else → :class:`meshcore.SerialConnection` + + Parameters: + target: Resolved, non-empty connection target. + baudrate: Baud rate for serial connections (ignored for BLE/TCP). + + 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) + + tcp_target = parse_tcp_target(target) + if tcp_target: + host, port = tcp_target + return TCPConnection(host, port) + + return SerialConnection(target, baudrate) + + async def _run_meshcore( iface: _MeshcoreInterface, - target: str | None, + target: str, connected_event: threading.Event, error_holder: list, ) -> None: @@ -555,17 +575,17 @@ async def _run_meshcore( Parameters: iface: Shared interface object for state and contact tracking. - target: Serial port path or BLE address to connect to. + target: Resolved, non-empty connection target (serial, BLE, or TCP). connected_event: Threading event signalled when the connection succeeds or fails, to unblock the calling ``connect()`` method. 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, SerialConnection + from meshcore import EventType, MeshCore mc: MeshCore | None = None try: - cx = SerialConnection(target, _DEFAULT_BAUDRATE) + cx = _make_connection(target, _DEFAULT_BAUDRATE) mc = MeshCore(cx) iface._mc = mc @@ -646,9 +666,9 @@ async def _run_meshcore( class MeshcoreProvider: """MeshCore ingestion provider. - Connects to a MeshCore node via serial port or BLE. TCP/IP connections - are not supported by the MeshCore protocol and will raise - :exc:`ValueError`. + Connects to a MeshCore node via serial port, BLE, or TCP/IP. The + connection type is inferred from the target string; see :meth:`connect` + for routing rules. The provider runs MeshCore's ``asyncio`` event loop in a background daemon thread. Incoming ``SELF_INFO``, ``CONTACTS``, ``NEW_CONTACT``, @@ -669,12 +689,21 @@ class MeshcoreProvider: def connect( self, *, active_candidate: str | None ) -> tuple[object, str | None, str | None]: - """Connect to a MeshCore node via serial or BLE. + """Connect to a MeshCore node via serial, BLE, or TCP. Starts an asyncio event loop in a background daemon thread, performs the MeshCore companion-protocol handshake, and blocks until the node's self-info is received or the timeout expires. + Connection type is inferred from *active_candidate* (or + :data:`~data.mesh_ingestor.config.CONNECTION`): + + * BLE MAC / UUID → :class:`meshcore.BLEConnection` + * ``host:port`` → :class:`meshcore.TCPConnection` + * serial path → :class:`meshcore.SerialConnection` + * ``None`` / empty → first candidate from + :func:`~data.mesh_ingestor.connection.default_serial_targets` + Parameters: active_candidate: Previously resolved connection target, or ``None`` to fall back to @@ -685,23 +714,19 @@ class MeshcoreProvider: :class:`~data.mesh_ingestor.provider.Provider` contract. Raises: - ValueError: When *target* looks like a TCP ``host:port`` address, - since MeshCore does not support IP connections. ConnectionError: When the node does not complete the handshake within :data:`_CONNECT_TIMEOUT_SECS` seconds. """ - target = active_candidate or config.CONNECTION + target: str | None = active_candidate or config.CONNECTION - if target and _is_tcp_target(target): - raise ValueError( - f"MeshCore does not support TCP/IP targets: {target!r}. " - "Provide a serial port (e.g. /dev/ttyUSB0) or BLE address." - ) + if not target: + candidates = default_serial_targets() + target = candidates[0] if candidates else "/dev/ttyACM0" config._debug_log( "Connecting to MeshCore node", context="meshcore.connect", - target=target or "auto", + target=target, ) iface = _MeshcoreInterface(target=target) diff --git a/data/requirements.txt b/data/requirements.txt index 4d143dc..6aadee5 100644 --- a/data/requirements.txt +++ b/data/requirements.txt @@ -1,6 +1,7 @@ # Production dependencies meshtastic>=2.5.0 meshcore>=2.3.5 +bleak>=0.21.0 protobuf>=5.27.2 # Development dependencies (optional) diff --git a/tests/test_connection_unit.py b/tests/test_connection_unit.py new file mode 100644 index 0000000..26a7d74 --- /dev/null +++ b/tests/test_connection_unit.py @@ -0,0 +1,256 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for :mod:`data.mesh_ingestor.connection`.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from data.mesh_ingestor.connection import ( # noqa: E402 + BLE_ADDRESS_RE, + DEFAULT_TCP_PORT, + default_serial_targets, + parse_ble_target, + parse_tcp_target, +) + +# --------------------------------------------------------------------------- +# parse_ble_target +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + # MAC addresses — returned upper-cased + ("AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF"), + ("aa:bb:cc:dd:ee:ff", "AA:BB:CC:DD:EE:FF"), + ("AA:BB:CC:DD:EE:12", "AA:BB:CC:DD:EE:12"), + # UUID (macOS format) + ( + "12345678-1234-1234-1234-123456789abc", + "12345678-1234-1234-1234-123456789ABC", + ), + ( + "12345678-1234-1234-1234-123456789ABC", + "12345678-1234-1234-1234-123456789ABC", + ), + ], +) +def test_parse_ble_target_accepts_ble_addresses(value, expected): + """parse_ble_target must return the normalised address for valid BLE formats.""" + assert parse_ble_target(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "/dev/ttyUSB0", + "/dev/ttyACM0", + "COM3", + "hostname:4403", + "192.168.1.1:4403", + "", + " ", + "AA:BB:CC:DD:EE", # too short — only 5 groups + "ZZ:BB:CC:DD:EE:FF", # invalid hex + ], +) +def test_parse_ble_target_rejects_non_ble(value): + """parse_ble_target must return None for serial paths, TCP targets, and malformed inputs.""" + assert parse_ble_target(value) is None + + +def test_parse_ble_target_none_input(): + """parse_ble_target must return None for None input.""" + assert parse_ble_target(None) is None # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# parse_tcp_target +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected_host,expected_port", + [ + # hostname:port + ("meshcore-node.local:4403", "meshcore-node.local", 4403), + ("meshnode.local:4403", "meshnode.local", 4403), + ("hostname:1234", "hostname", 1234), + ("otherhost:80", "otherhost", 80), + # IP:port + ("192.168.1.1:4403", "192.168.1.1", 4403), + ("10.0.0.1:9000", "10.0.0.1", 9000), + # With scheme prefix + ("tcp://meshnode.local:4403", "meshnode.local", 4403), + ("http://192.168.1.1:4403", "192.168.1.1", 4403), + # IPv6 with brackets + ("[::1]:4403", "::1", 4403), + ("[2001:db8::1]:8080", "2001:db8::1", 8080), + ], +) +def test_parse_tcp_target_accepts_tcp(value, expected_host, expected_port): + """parse_tcp_target must return (host, port) for valid TCP target strings.""" + result = parse_tcp_target(value) + assert result is not None + host, port = result + assert host == expected_host + assert port == expected_port + + +@pytest.mark.parametrize( + "value", + [ + # Serial paths + "/dev/ttyUSB0", + "/dev/ttyACM0", + "COM3", + # BLE MACs — multiple colons, no valid port + "AA:BB:CC:DD:EE:FF", + "AA:BB:CC:DD:EE:12", + # UUIDs — hyphens, no colon + "12345678-1234-1234-1234-123456789abc", + # Bare hostname without port + "meshcore-node.local", + # Empty / whitespace + "", + " ", + # Port out of range + "host:0", + "host:65536", + # Non-numeric port + "host:notaport", + ], +) +def test_parse_tcp_target_rejects_non_tcp(value): + """parse_tcp_target must return None for serial paths, BLE addresses, and malformed inputs.""" + assert parse_tcp_target(value) is None + + +def test_parse_tcp_target_none_input(): + """parse_tcp_target must return None for None input.""" + assert parse_tcp_target(None) is None # type: ignore[arg-type] + + +def test_parse_tcp_target_default_port_for_bracketed_ipv6_no_port(): + """parse_tcp_target must use DEFAULT_TCP_PORT for bracketed IPv6 without port.""" + result = parse_tcp_target("[::1]") + assert result == ("::1", DEFAULT_TCP_PORT) + + +@pytest.mark.parametrize( + "value", + [ + "[::1", # no closing bracket + "[]:4403", # empty host in brackets + "[::1]:abc", # non-numeric port after bracket + "[::1]:0", # port out of range (low) + "[::1]:65536", # port out of range (high) + ], +) +def test_parse_tcp_target_rejects_malformed_ipv6(value): + """parse_tcp_target must return None for malformed bracketed IPv6 targets.""" + assert parse_tcp_target(value) is None + + +# --------------------------------------------------------------------------- +# default_serial_targets +# --------------------------------------------------------------------------- + + +def test_default_serial_targets_returns_list(): + """default_serial_targets must return a non-empty list.""" + targets = default_serial_targets() + assert isinstance(targets, list) + assert len(targets) > 0 + + +def test_default_serial_targets_includes_fallback(): + """default_serial_targets always includes /dev/ttyACM0 as a fallback.""" + targets = default_serial_targets() + assert "/dev/ttyACM0" in targets + + +def test_default_serial_targets_no_duplicates(): + """default_serial_targets must not return duplicate paths.""" + targets = default_serial_targets() + assert len(targets) == len(set(targets)) + + +def test_default_serial_targets_deduplicates_glob_results(): + """default_serial_targets must deduplicate paths returned by multiple globs.""" + + def _fake_glob(pattern): + if "ttyACM" in pattern: + return ["/dev/ttyACM0", "/dev/ttyACM1"] + if "ttyUSB" in pattern: + return ["/dev/ttyACM0"] # intentional duplicate across patterns + return [] + + with patch("data.mesh_ingestor.connection.glob.glob", side_effect=_fake_glob): + targets = default_serial_targets() + + assert targets.count("/dev/ttyACM0") == 1 + assert "/dev/ttyACM1" in targets + # ttyACM0 already found by glob so fallback append must not re-add it + assert targets.count("/dev/ttyACM0") == 1 + + +def test_default_serial_targets_omits_fallback_when_ttyacm0_found(): + """default_serial_targets must not append /dev/ttyACM0 when glob already found it.""" + + def _fake_glob(pattern): + if "ttyACM" in pattern: + return ["/dev/ttyACM0"] + return [] + + with patch("data.mesh_ingestor.connection.glob.glob", side_effect=_fake_glob): + targets = default_serial_targets() + + # present exactly once — from glob, not appended again + assert targets.count("/dev/ttyACM0") == 1 + + +# --------------------------------------------------------------------------- +# BLE_ADDRESS_RE sanity +# --------------------------------------------------------------------------- + + +def test_ble_address_re_mac(): + """BLE_ADDRESS_RE matches a canonical 6-byte MAC address.""" + assert BLE_ADDRESS_RE.fullmatch("AA:BB:CC:DD:EE:FF") is not None + + +def test_ble_address_re_uuid(): + """BLE_ADDRESS_RE matches a standard 128-bit UUID.""" + assert BLE_ADDRESS_RE.fullmatch("12345678-1234-1234-1234-123456789abc") is not None + + +def test_ble_address_re_rejects_tcp(): + """BLE_ADDRESS_RE must not match a hostname:port string.""" + assert BLE_ADDRESS_RE.fullmatch("hostname:4403") is None + + +def test_ble_address_re_rejects_partial_mac(): + """BLE_ADDRESS_RE must not match an incomplete MAC address.""" + assert BLE_ADDRESS_RE.fullmatch("AA:BB:CC:DD:EE") is None diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 7d37161..f1af416 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -3023,7 +3023,7 @@ def test_default_serial_targets_deduplicates(mesh_module, monkeypatch): return ["/dev/ttyACM1"] return [] - monkeypatch.setattr(mesh.interfaces.glob, "glob", fake_glob) + monkeypatch.setattr(mesh.connection.glob, "glob", fake_glob) targets = mesh._default_serial_targets() diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py index 2e5eda8..c1be7da 100644 --- a/tests/test_provider_unit.py +++ b/tests/test_provider_unit.py @@ -30,12 +30,13 @@ from data.mesh_ingestor.provider import Provider # noqa: E402 - path setup from data.mesh_ingestor.providers.meshtastic import ( # noqa: E402 - path setup MeshtasticProvider, ) +from data.mesh_ingestor.connection import parse_tcp_target # noqa: E402 - path setup from data.mesh_ingestor.providers.meshcore import ( # noqa: E402 - path setup MeshcoreProvider, _MeshcoreInterface, _contact_to_node_dict, _derive_message_id, - _is_tcp_target, + _make_connection, _make_event_handlers, _meshcore_node_id, _process_contact_update, @@ -221,14 +222,20 @@ def test_meshcore_subscribe_returns_empty_list(): "otherhost:80", ], ) -def test_meshcore_connect_rejects_tcp_targets(target, monkeypatch): - """connect() must raise ValueError for TCP host:port targets.""" +def test_meshcore_connect_accepts_tcp_targets(target, monkeypatch): + """connect() must succeed for TCP host:port targets.""" import data.mesh_ingestor.providers.meshcore as _mod + monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore()) monkeypatch.setattr(_mod.config, "CONNECTION", None) monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None) - with pytest.raises(ValueError, match="TCP/IP"): - MeshcoreProvider().connect(active_candidate=target) + iface, resolved, next_candidate = MeshcoreProvider().connect( + active_candidate=target + ) + assert iface is not None + assert resolved == target + assert next_candidate == target + iface.close() def _fake_run_meshcore(*, error=None, host_node_id=None): @@ -263,11 +270,10 @@ def _fake_run_meshcore(*, error=None, host_node_id=None): "COM3", "AA:BB:CC:DD:EE:FF", "12345678-1234-1234-1234-123456789abc", - None, ], ) def test_meshcore_connect_accepts_serial_ble_targets(target, monkeypatch): - """connect() must succeed for serial ports, BLE addresses, and None (auto).""" + """connect() must succeed for explicit serial ports and BLE addresses.""" import data.mesh_ingestor.providers.meshcore as _mod monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore()) @@ -282,6 +288,78 @@ def test_meshcore_connect_accepts_serial_ble_targets(target, monkeypatch): iface.close() +def test_meshcore_connect_auto_discovers_serial(monkeypatch): + """connect() with no target must resolve to the first serial candidate.""" + import data.mesh_ingestor.providers.meshcore as _mod + + monkeypatch.setattr(_mod, "_run_meshcore", _fake_run_meshcore()) + monkeypatch.setattr(_mod.config, "CONNECTION", None) + monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None) + monkeypatch.setattr( + _mod, "default_serial_targets", lambda: ["/dev/ttyACM0", "/dev/ttyUSB0"] + ) + iface, resolved, next_candidate = MeshcoreProvider().connect(active_candidate=None) + assert iface is not None + assert resolved == "/dev/ttyACM0" + assert next_candidate == "/dev/ttyACM0" + iface.close() + + +@pytest.mark.parametrize( + "target,expected_class_name", + [ + # Serial paths + ("/dev/ttyUSB0", "SerialConnection"), + ("/dev/ttyACM0", "SerialConnection"), + ("COM3", "SerialConnection"), + # BLE targets + ("AA:BB:CC:DD:EE:FF", "BLEConnection"), + ("12345678-1234-1234-1234-123456789abc", "BLEConnection"), + # TCP targets + ("hostname:4403", "TCPConnection"), + ("192.168.1.1:4403", "TCPConnection"), + ("meshcore-node.local:4403", "TCPConnection"), + ], +) +def test_make_connection_routes_to_correct_class( + target, expected_class_name, monkeypatch +): + """_make_connection must instantiate the correct meshcore connection class.""" + import types + import data.mesh_ingestor.providers.meshcore as _mod + + instances: list = [] + + def _make_mock(name): + def _cls(*args, **kwargs): + obj = types.SimpleNamespace(name=name, args=args, kwargs=kwargs) + instances.append(obj) + return obj + + _cls.__name__ = name + return _cls + + fake_meshcore = types.ModuleType("meshcore") + fake_meshcore.BLEConnection = _make_mock("BLEConnection") + fake_meshcore.SerialConnection = _make_mock("SerialConnection") + fake_meshcore.TCPConnection = _make_mock("TCPConnection") + + import sys as _sys + + original = _sys.modules.get("meshcore") + try: + _sys.modules["meshcore"] = fake_meshcore + result = _make_connection(target, 115200) + finally: + if original is None: + _sys.modules.pop("meshcore", None) + else: + _sys.modules["meshcore"] = original + + assert len(instances) == 1 + assert instances[0].name == expected_class_name + + def test_meshcore_connect_returns_closeable_interface(monkeypatch): """The interface returned by connect() must expose a close() method.""" import data.mesh_ingestor.providers.meshcore as _mod @@ -359,19 +437,19 @@ def test_meshcore_node_snapshot_items_with_contacts(monkeypatch): iface.close() -def test_is_tcp_target_detects_host_port(): - """_is_tcp_target must return True for host:port strings.""" - assert _is_tcp_target("meshnode.local:4403") is True - assert _is_tcp_target("meshtastic.local:4403") is True +def test_parse_tcp_target_detects_host_port(): + """parse_tcp_target must return (host, port) for host:port strings.""" + assert parse_tcp_target("meshnode.local:4403") == ("meshnode.local", 4403) + assert parse_tcp_target("meshtastic.local:4403") == ("meshtastic.local", 4403) -def test_is_tcp_target_rejects_serial_ble(): - """_is_tcp_target must return False for serial paths and BLE addresses.""" - assert _is_tcp_target("/dev/ttyUSB0") is False - assert _is_tcp_target("AA:BB:CC:DD:EE:FF") is False - assert _is_tcp_target("COM3") is False +def test_parse_tcp_target_rejects_serial_ble(): + """parse_tcp_target must return None for serial paths and BLE addresses.""" + assert parse_tcp_target("/dev/ttyUSB0") is None + assert parse_tcp_target("AA:BB:CC:DD:EE:FF") is None + assert parse_tcp_target("COM3") is None # BLE MAC address whose final octet is all-decimal must not be a false positive. - assert _is_tcp_target("AA:BB:CC:DD:EE:12") is False + assert parse_tcp_target("AA:BB:CC:DD:EE:12") is None def test_record_meshcore_message_skipped_without_debug(monkeypatch, tmp_path): @@ -1215,10 +1293,20 @@ def _make_fake_meshcore_mod( def __init__(self, target, baudrate): pass + class _FakeBLEConnection: + def __init__(self, address=None): + pass + + class _FakeTCPConnection: + def __init__(self, host, port): + pass + return types.SimpleNamespace( EventType=EventType, MeshCore=_FakeMeshCore, SerialConnection=_FakeSerialConnection, + BLEConnection=_FakeBLEConnection, + TCPConnection=_FakeTCPConnection, )