data/meshcore: fix ble and enable tcp (#669)

* data/meshcore: fix ble and enable tcp

* ingestor: address review comments

* ingestor: address review comments
This commit is contained in:
l5y
2026-04-02 22:31:33 +02:00
committed by GitHub
parent 4fa0745d1b
commit 09ea277a40
8 changed files with 605 additions and 106 deletions
+2 -1
View File
@@ -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"])
+163
View File
@@ -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
+14 -49
View File
@@ -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]:
+63 -38
View File
@@ -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 15 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)
+1
View File
@@ -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)
+256
View File
@@ -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
+1 -1
View File
@@ -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()
+105 -17
View File
@@ -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,
)