mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-11 11:23:58 +02:00
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:
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user