From c136c5cf261f5db572e274b614f67ae5ed3225de Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 5 Oct 2025 20:48:23 +0200 Subject: [PATCH] Add BLE and fallback mesh interface handling (#219) * Add BLE and fallback mesh interface support * Handle SIGINT by propagating KeyboardInterrupt * Guard optional BLE dependency * run black --- data/mesh.py | 284 ++++++++++++++++++++++++++++++++++----------- tests/test_mesh.py | 106 +++++++++++++++-- 2 files changed, 315 insertions(+), 75 deletions(-) diff --git a/data/mesh.py b/data/mesh.py index f7eb8ce..1634c91 100644 --- a/data/mesh.py +++ b/data/mesh.py @@ -24,12 +24,23 @@ entry point that performs these synchronisation tasks. import base64 import dataclasses +import glob import heapq import ipaddress import itertools -import json, os, time, threading, signal, urllib.request, urllib.error, urllib.parse +import json import math +import os +import re +import signal +import threading +import time +import urllib.error +import urllib.parse +import urllib.request from collections.abc import Mapping +from functools import lru_cache +from typing import TYPE_CHECKING from meshtastic.serial_interface import SerialInterface from meshtastic.tcp_interface import TCPInterface @@ -38,8 +49,16 @@ from google.protobuf.json_format import MessageToDict from google.protobuf.message import Message as ProtoMessage from google.protobuf.message import DecodeError +if TYPE_CHECKING: # pragma: no cover - import only used for type checking + from meshtastic.ble_interface import BLEInterface as _BLEInterface + +# Exposed for tests and backward compatibility; resolved lazily in +# :func:`_load_ble_interface` so importing this module does not require the BLE +# extras to be installed. +BLEInterface = None + # --- Config (env overrides) --------------------------------------------------- -PORT = os.environ.get("MESH_SERIAL", "/dev/ttyACM0") +PORT = os.environ.get("MESH_SERIAL") SNAPSHOT_SECS = int(os.environ.get("MESH_SNAPSHOT_SECS", "60")) CHANNEL_INDEX = int(os.environ.get("MESH_CHANNEL_INDEX", "0")) DEBUG = os.environ.get("DEBUG") == "1" @@ -51,6 +70,18 @@ API_TOKEN = os.environ.get("API_TOKEN", "") _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*", +) + +_BLE_ADDRESS_RE = re.compile(r"^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$") def _debug_log(message: str): @@ -86,6 +117,19 @@ class _DummySerialInterface: pass +def _parse_ble_target(value: str) -> str | None: + """Return an uppercase BLE MAC address when ``value`` matches the 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_network_target(value: str) -> tuple[str, int] | None: """Return ``(host, port)`` when ``value`` is an IP address string. @@ -138,8 +182,27 @@ def _parse_network_target(value: str) -> tuple[str, int] | None: return _validated_result(value, None) -def _create_serial_interface(port: str): - """Return an appropriate serial interface for ``port``. +@lru_cache(maxsize=1) +def _load_ble_interface(): + """Return :class:`meshtastic.ble_interface.BLEInterface` when available.""" + + global BLEInterface + if BLEInterface is not None: + return BLEInterface + + try: + from meshtastic.ble_interface import BLEInterface as _resolved_interface + except ImportError as exc: # pragma: no cover - exercised in non-BLE envs + raise RuntimeError( + "BLE interface requested but the Meshtastic BLE dependencies are not installed. " + "Install the 'meshtastic[ble]' extra to enable BLE support." + ) from exc + BLEInterface = _resolved_interface + return _resolved_interface + + +def _create_serial_interface(port: str) -> tuple[object, str]: + """Return an appropriate mesh interface for ``port``. Passing ``mock`` (case-insensitive) or an empty value skips hardware access and returns :class:`_DummySerialInterface`. This makes it possible to run @@ -150,13 +213,63 @@ def _create_serial_interface(port: str): port_value = (port or "").strip() if port_value.lower() in {"", "mock", "none", "null", "disabled"}: _debug_log(f"using dummy serial interface for port={port_value!r}") - return _DummySerialInterface() + return _DummySerialInterface(), "mock" + ble_target = _parse_ble_target(port_value) + if ble_target: + _debug_log(f"using BLE interface for address={ble_target}") + return _load_ble_interface()(address=ble_target), ble_target network_target = _parse_network_target(port_value) if network_target: host, tcp_port = network_target _debug_log(f"using TCP interface for host={host!r} port={tcp_port!r}") - return TCPInterface(hostname=host, portNumber=tcp_port) - return SerialInterface(devPath=port_value) + return ( + TCPInterface(hostname=host, portNumber=tcp_port), + f"tcp://{host}:{tcp_port}", + ) + _debug_log(f"using serial interface for port={port_value!r}") + return SerialInterface(devPath=port_value), port_value + + +class NoAvailableMeshInterface(RuntimeError): + """Raised when no default mesh interface can be created.""" + + +def _default_serial_targets() -> list[str]: + """Return a list of 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 + + +def _create_default_interface() -> tuple[object, str]: + """Attempt to create the default mesh interface, raising on failure.""" + + errors: list[tuple[str, Exception]] = [] + for candidate in _default_serial_targets(): + try: + return _create_serial_interface(candidate) + except Exception as exc: # pragma: no cover - hardware dependent + errors.append((candidate, exc)) + _debug_log(f"failed to open serial candidate {candidate!r}: {exc}") + try: + return _create_serial_interface(_DEFAULT_TCP_TARGET) + except Exception as exc: # pragma: no cover - network dependent + errors.append((_DEFAULT_TCP_TARGET, exc)) + _debug_log(f"failed to open TCP fallback {_DEFAULT_TCP_TARGET!r}: {exc}") + if errors: + summary = "; ".join(f"{target}: {error}" for target, error in errors) + raise NoAvailableMeshInterface( + f"no mesh interface available ({summary})" + ) from errors[-1][1] + raise NoAvailableMeshInterface("no mesh interface available") # --- POST queue ---------------------------------------------------------------- @@ -1502,83 +1615,118 @@ def main(): pass iface = None + resolved_target = None retry_delay = max(0.0, _RECONNECT_INITIAL_DELAY_SECS) stop = threading.Event() initial_snapshot_sent = False - def handle_sig(*_): + def handle_sigterm(*_): """Stop the daemon when a termination signal is received.""" stop.set() - signal.signal(signal.SIGINT, handle_sig) - signal.signal(signal.SIGTERM, handle_sig) + def handle_sigint(signum, frame): + """Handle ``SIGINT`` by stopping and propagating ``KeyboardInterrupt``.""" + + stop.set() + signal.default_int_handler(signum, frame) + + signal.signal(signal.SIGINT, handle_sigint) + signal.signal(signal.SIGTERM, handle_sigterm) target = INSTANCE or "(no POTATOMESH_INSTANCE)" + configured_port = PORT + active_candidate = configured_port + announced_target = False print( - f"Mesh daemon: nodes+messages → {target} | port={PORT} | channel={CHANNEL_INDEX}" + f"Mesh daemon: nodes+messages → {target} | port={configured_port or 'auto'} | channel={CHANNEL_INDEX}" ) - while not stop.is_set(): - if iface is None: - try: - iface = _create_serial_interface(PORT) - retry_delay = max(0.0, _RECONNECT_INITIAL_DELAY_SECS) - initial_snapshot_sent = False - except Exception as exc: - print(f"[warn] failed to create mesh interface: {exc}") - stop.wait(retry_delay) - if _RECONNECT_MAX_DELAY_SECS > 0: - retry_delay = min( - ( - retry_delay * 2 - if retry_delay - else _RECONNECT_INITIAL_DELAY_SECS - ), - _RECONNECT_MAX_DELAY_SECS, + try: + while not stop.is_set(): + if iface is None: + try: + if active_candidate: + iface, resolved_target = _create_serial_interface( + active_candidate + ) + else: + iface, resolved_target = _create_default_interface() + active_candidate = resolved_target + retry_delay = max(0.0, _RECONNECT_INITIAL_DELAY_SECS) + initial_snapshot_sent = False + if not announced_target and resolved_target: + print(f"[info] using mesh interface: {resolved_target}") + announced_target = True + except NoAvailableMeshInterface as exc: + print(f"[error] {exc}") + _close_interface(iface) + raise SystemExit(1) from exc + except Exception as exc: + candidate_desc = active_candidate or "auto" + print( + f"[warn] failed to create mesh interface ({candidate_desc}): {exc}" ) - continue + if configured_port is None: + active_candidate = None + announced_target = False + stop.wait(retry_delay) + if _RECONNECT_MAX_DELAY_SECS > 0: + retry_delay = min( + ( + retry_delay * 2 + if retry_delay + else _RECONNECT_INITIAL_DELAY_SECS + ), + _RECONNECT_MAX_DELAY_SECS, + ) + continue - if not initial_snapshot_sent: - try: - nodes = getattr(iface, "nodes", {}) or {} - node_items = _node_items_snapshot(nodes) - if node_items is None: - _debug_log("skipping node snapshot; nodes changed during iteration") - else: - processed_snapshot_item = False - for node_id, n in node_items: - processed_snapshot_item = True - try: - upsert_node(node_id, n) - except Exception as e: - print( - f"[warn] failed to update node snapshot for {node_id}: {e}" - ) - if DEBUG: - _debug_log(f"node object: {n!r}") - if processed_snapshot_item: - initial_snapshot_sent = True - except Exception as e: - print(f"[warn] failed to update node snapshot: {e}") - _close_interface(iface) - iface = None - stop.wait(retry_delay) - if _RECONNECT_MAX_DELAY_SECS > 0: - retry_delay = min( - ( - retry_delay * 2 - if retry_delay - else _RECONNECT_INITIAL_DELAY_SECS - ), - _RECONNECT_MAX_DELAY_SECS, - ) - continue + if not initial_snapshot_sent: + try: + nodes = getattr(iface, "nodes", {}) or {} + node_items = _node_items_snapshot(nodes) + if node_items is None: + _debug_log( + "skipping node snapshot; nodes changed during iteration" + ) + else: + processed_snapshot_item = False + for node_id, n in node_items: + processed_snapshot_item = True + try: + upsert_node(node_id, n) + except Exception as e: + print( + f"[warn] failed to update node snapshot for {node_id}: {e}" + ) + if DEBUG: + _debug_log(f"node object: {n!r}") + if processed_snapshot_item: + initial_snapshot_sent = True + except Exception as e: + print(f"[warn] failed to update node snapshot: {e}") + _close_interface(iface) + iface = None + stop.wait(retry_delay) + if _RECONNECT_MAX_DELAY_SECS > 0: + retry_delay = min( + ( + retry_delay * 2 + if retry_delay + else _RECONNECT_INITIAL_DELAY_SECS + ), + _RECONNECT_MAX_DELAY_SECS, + ) + continue - retry_delay = max(0.0, _RECONNECT_INITIAL_DELAY_SECS) - stop.wait(SNAPSHOT_SECS) - - _close_interface(iface) + retry_delay = max(0.0, _RECONNECT_INITIAL_DELAY_SECS) + stop.wait(SNAPSHOT_SECS) + except KeyboardInterrupt: + _debug_log("received KeyboardInterrupt; shutting down") + stop.set() + finally: + _close_interface(iface) if __name__ == "__main__": diff --git a/tests/test_mesh.py b/tests/test_mesh.py index b378f10..a192b2e 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -48,9 +48,21 @@ def mesh_module(monkeypatch): tcp_interface_mod.TCPInterface = DummyTCPInterface + ble_interface_mod = types.ModuleType("meshtastic.ble_interface") + + class DummyBLEInterface: + def __init__(self, *_, **__): + self.closed = False + + def close(self): + self.closed = True + + ble_interface_mod.BLEInterface = DummyBLEInterface + meshtastic_mod = types.ModuleType("meshtastic") meshtastic_mod.serial_interface = serial_interface_mod meshtastic_mod.tcp_interface = tcp_interface_mod + meshtastic_mod.ble_interface = ble_interface_mod if real_protobuf is not None: meshtastic_mod.protobuf = real_protobuf @@ -59,6 +71,7 @@ def mesh_module(monkeypatch): sys.modules, "meshtastic.serial_interface", serial_interface_mod ) monkeypatch.setitem(sys.modules, "meshtastic.tcp_interface", tcp_interface_mod) + monkeypatch.setitem(sys.modules, "meshtastic.ble_interface", ble_interface_mod) if real_protobuf is not None: monkeypatch.setitem(sys.modules, "meshtastic.protobuf", real_protobuf) @@ -144,8 +157,9 @@ def test_snapshot_interval_defaults_to_60_seconds(mesh_module): def test_create_serial_interface_allows_mock(mesh_module, value): mesh = mesh_module - iface = mesh._create_serial_interface(value) + iface, resolved = mesh._create_serial_interface(value) + assert resolved == "mock" assert isinstance(iface.nodes, dict) iface.close() @@ -161,9 +175,10 @@ def test_create_serial_interface_uses_serial_module(mesh_module, monkeypatch): monkeypatch.setattr(mesh, "SerialInterface", fake_interface) - iface = mesh._create_serial_interface("/dev/ttyTEST") + iface, resolved = mesh._create_serial_interface("/dev/ttyTEST") assert created["devPath"] == "/dev/ttyTEST" + assert resolved == "/dev/ttyTEST" assert iface.nodes == {"!foo": sentinel} @@ -178,9 +193,10 @@ def test_create_serial_interface_uses_tcp_for_ip(mesh_module, monkeypatch): monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface) - iface = mesh._create_serial_interface("192.168.1.25:4500") + iface, resolved = mesh._create_serial_interface("192.168.1.25:4500") assert created == {"hostname": "192.168.1.25", "portNumber": 4500} + assert resolved == "tcp://192.168.1.25:4500" assert iface.nodes == {} @@ -195,10 +211,11 @@ def test_create_serial_interface_defaults_tcp_port(mesh_module, monkeypatch): monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface) - mesh._create_serial_interface("tcp://10.20.30.40") + _, resolved = mesh._create_serial_interface("tcp://10.20.30.40") assert created["hostname"] == "10.20.30.40" assert created["portNumber"] == mesh._DEFAULT_TCP_PORT + assert resolved == "tcp://10.20.30.40:4403" def test_create_serial_interface_plain_ip(mesh_module, monkeypatch): @@ -212,10 +229,67 @@ def test_create_serial_interface_plain_ip(mesh_module, monkeypatch): monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface) - mesh._create_serial_interface(" 192.168.50.10 ") + _, resolved = mesh._create_serial_interface(" 192.168.50.10 ") assert created["hostname"] == "192.168.50.10" assert created["portNumber"] == mesh._DEFAULT_TCP_PORT + assert resolved == "tcp://192.168.50.10:4403" + + +def test_create_serial_interface_ble(mesh_module, monkeypatch): + mesh = mesh_module + created = {} + + def fake_ble_interface(*, address=None, **_): + created["address"] = address + return SimpleNamespace(nodes={}, close=lambda: None) + + monkeypatch.setattr(mesh, "BLEInterface", fake_ble_interface) + + iface, resolved = mesh._create_serial_interface("ed:4d:9e:95:cf:60") + + assert created["address"] == "ED:4D:9E:95:CF:60" + assert resolved == "ED:4D:9E:95:CF:60" + assert iface.nodes == {} + + +def test_create_default_interface_falls_back_to_tcp(mesh_module, monkeypatch): + mesh = mesh_module + attempts = [] + + def fake_targets(): + return ["/dev/ttyFAIL"] + + def fake_create(port): + attempts.append(port) + if port.startswith("/dev/tty"): + raise RuntimeError("missing serial device") + return SimpleNamespace(nodes={}, close=lambda: None), "tcp://127.0.0.1:4403" + + monkeypatch.setattr(mesh, "_default_serial_targets", fake_targets) + monkeypatch.setattr(mesh, "_create_serial_interface", fake_create) + + iface, resolved = mesh._create_default_interface() + + assert attempts == ["/dev/ttyFAIL", mesh._DEFAULT_TCP_TARGET] + assert resolved == "tcp://127.0.0.1:4403" + assert iface.nodes == {} + + +def test_create_default_interface_raises_when_unavailable(mesh_module, monkeypatch): + mesh = mesh_module + + monkeypatch.setattr(mesh, "_default_serial_targets", lambda: ["/dev/ttyFAIL"]) + + def always_fail(port): + raise RuntimeError(f"boom for {port}") + + monkeypatch.setattr(mesh, "_create_serial_interface", always_fail) + + with pytest.raises(mesh.NoAvailableMeshInterface) as exc_info: + mesh._create_default_interface() + + assert "/dev/ttyFAIL" in str(exc_info.value) def test_node_to_dict_handles_nested_structures(mesh_module): @@ -863,8 +937,9 @@ def test_main_retries_interface_creation(mesh_module, monkeypatch): attempts.append(port) if len(attempts) < 3: raise RuntimeError("boom") - return iface + return iface, port + monkeypatch.setattr(mesh, "PORT", "/dev/ttyTEST") monkeypatch.setattr(mesh, "_create_serial_interface", fake_create) monkeypatch.setattr(mesh.threading, "Event", DummyEvent) monkeypatch.setattr(mesh.signal, "signal", lambda *_, **__: None) @@ -918,13 +993,14 @@ def test_main_recreates_interface_after_snapshot_error(mesh_module, monkeypatch) interface = FlakyInterface(fail_first) interfaces.append(interface) - return interface + return interface, port upsert_calls = [] def record_upsert(node_id, node): upsert_calls.append(node_id) + monkeypatch.setattr(mesh, "PORT", "/dev/ttyTEST") monkeypatch.setattr(mesh, "_create_serial_interface", fake_create) monkeypatch.setattr(mesh, "upsert_node", record_upsert) monkeypatch.setattr(mesh.threading, "Event", DummyEvent) @@ -940,6 +1016,22 @@ def test_main_recreates_interface_after_snapshot_error(mesh_module, monkeypatch) assert upsert_calls == ["!node"] +def test_main_exits_when_defaults_unavailable(mesh_module, monkeypatch): + mesh = mesh_module + + def fail_default(): + raise mesh.NoAvailableMeshInterface("no interface available") + + monkeypatch.setattr(mesh, "PORT", None) + monkeypatch.setattr(mesh, "_create_default_interface", fail_default) + monkeypatch.setattr(mesh.signal, "signal", lambda *_, **__: None) + + with pytest.raises(SystemExit) as exc_info: + mesh.main() + + assert exc_info.value.code == 1 + + def test_store_packet_dict_uses_top_level_channel(mesh_module, monkeypatch): mesh = mesh_module captured = []