diff --git a/README.md b/README.md index 555e913..83f4717 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,9 @@ accepts data through the API POST endpoints. Benefit is, here multiple nodes acr community can feed the dashboard with data. The web app handles messages and nodes by ID and there will be no duplication. -For convenience, the directory `./data` contains a Python ingestor. It connects to a local -Meshtastic node via serial port to gather nodes and messages seen by the node. +For convenience, the directory `./data` contains a Python ingestor. It connects to a +Meshtastic node via serial port or to a remote device that exposes the Meshtastic TCP +interface to gather nodes and messages seen by the node. ```bash pacman -S python @@ -130,7 +131,8 @@ Mesh daemon: nodes+messages → http://127.0.0.1 | port=41447 | channel=0 Run the script with `POTATOMESH_INSTANCE` and `API_TOKEN` to keep updating node records and parsing new incoming messages. Enable debug output with `DEBUG=1`, -specify the serial port with `MESH_SERIAL` (default `/dev/ttyACM0`), etc. +specify the serial port with `MESH_SERIAL` (default `/dev/ttyACM0`) or set it to an IP +address (for example `192.168.1.20:4403`) to use the Meshtastic TCP interface. ## License diff --git a/data/mesh.py b/data/mesh.py index f8fa689..effed54 100644 --- a/data/mesh.py +++ b/data/mesh.py @@ -25,12 +25,14 @@ entry point that performs these synchronisation tasks. import base64 import dataclasses import heapq +import ipaddress import itertools -import json, os, time, threading, signal, urllib.request, urllib.error +import json, os, time, threading, signal, urllib.request, urllib.error, urllib.parse import math from collections.abc import Mapping from meshtastic.serial_interface import SerialInterface +from meshtastic.tcp_interface import TCPInterface from pubsub import pub from google.protobuf.json_format import MessageToDict from google.protobuf.message import Message as ProtoMessage @@ -48,6 +50,9 @@ API_TOKEN = os.environ.get("API_TOKEN", "") # --- Serial interface helpers -------------------------------------------------- +_DEFAULT_TCP_PORT = 4403 + + class _DummySerialInterface: """In-memory replacement for ``meshtastic.serial_interface.SerialInterface``. @@ -65,6 +70,58 @@ class _DummySerialInterface: pass +def _parse_network_target(value: str) -> tuple[str, int] | None: + """Return ``(host, port)`` when ``value`` is an IP address string. + + The ingestor accepts values such as ``192.168.1.10`` or + ``tcp://192.168.1.10:4500`` for ``MESH_SERIAL`` to support Meshtastic + devices shared via TCP. Serial device paths (``/dev/ttyACM0``) are ignored + by returning ``None``. + """ + + if not value: + return None + + value = value.strip() + if not value: + return None + + def _validated_result(host: str | None, port: int | None): + if not host: + return None + try: + ipaddress.ip_address(host) + except ValueError: + return None + return host, port or _DEFAULT_TCP_PORT + + parsed_values = [] + if "://" in value: + parsed_values.append(urllib.parse.urlparse(value, scheme="tcp")) + parsed_values.append(urllib.parse.urlparse(f"//{value}", scheme="tcp")) + + for parsed in parsed_values: + try: + port = parsed.port + except ValueError: + port = None + result = _validated_result(parsed.hostname, port) + if result: + return result + + if value.count(":") == 1 and not value.startswith("["): + host, _, port_text = value.partition(":") + try: + port = int(port_text) if port_text else None + except ValueError: + port = None + result = _validated_result(host, port) + if result: + return result + + return _validated_result(value, None) + + def _create_serial_interface(port: str): """Return an appropriate serial interface for ``port``. @@ -79,6 +136,12 @@ def _create_serial_interface(port: str): if DEBUG: print(f"[debug] using dummy serial interface for port={port_value!r}") return _DummySerialInterface() + network_target = _parse_network_target(port_value) + if network_target: + host, tcp_port = network_target + if DEBUG: + print("[debug] using TCP interface for host=" f"{host!r} port={tcp_port!r}") + return TCPInterface(hostname=host, portNumber=tcp_port) return SerialInterface(devPath=port_value) diff --git a/tests/test_mesh.py b/tests/test_mesh.py index abc7d83..eaf6b18 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -37,8 +37,20 @@ def mesh_module(monkeypatch): serial_interface_mod.SerialInterface = DummySerialInterface + tcp_interface_mod = types.ModuleType("meshtastic.tcp_interface") + + class DummyTCPInterface: + def __init__(self, *_, **__): + self.closed = False + + def close(self): + self.closed = True + + tcp_interface_mod.TCPInterface = DummyTCPInterface + meshtastic_mod = types.ModuleType("meshtastic") meshtastic_mod.serial_interface = serial_interface_mod + meshtastic_mod.tcp_interface = tcp_interface_mod if real_protobuf is not None: meshtastic_mod.protobuf = real_protobuf @@ -46,6 +58,7 @@ def mesh_module(monkeypatch): monkeypatch.setitem( sys.modules, "meshtastic.serial_interface", serial_interface_mod ) + monkeypatch.setitem(sys.modules, "meshtastic.tcp_interface", tcp_interface_mod) if real_protobuf is not None: monkeypatch.setitem(sys.modules, "meshtastic.protobuf", real_protobuf) @@ -154,6 +167,57 @@ def test_create_serial_interface_uses_serial_module(mesh_module, monkeypatch): assert iface.nodes == {"!foo": sentinel} +def test_create_serial_interface_uses_tcp_for_ip(mesh_module, monkeypatch): + mesh = mesh_module + created = {} + + def fake_tcp_interface(*, hostname, portNumber, **_): + created["hostname"] = hostname + created["portNumber"] = portNumber + return SimpleNamespace(nodes={}, close=lambda: None) + + monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface) + + iface = mesh._create_serial_interface("192.168.1.25:4500") + + assert created == {"hostname": "192.168.1.25", "portNumber": 4500} + assert iface.nodes == {} + + +def test_create_serial_interface_defaults_tcp_port(mesh_module, monkeypatch): + mesh = mesh_module + created = {} + + def fake_tcp_interface(*, hostname, portNumber, **_): + created["hostname"] = hostname + created["portNumber"] = portNumber + return SimpleNamespace(nodes={}, close=lambda: None) + + monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface) + + mesh._create_serial_interface("tcp://10.20.30.40") + + assert created["hostname"] == "10.20.30.40" + assert created["portNumber"] == mesh._DEFAULT_TCP_PORT + + +def test_create_serial_interface_plain_ip(mesh_module, monkeypatch): + mesh = mesh_module + created = {} + + def fake_tcp_interface(*, hostname, portNumber, **_): + created["hostname"] = hostname + created["portNumber"] = portNumber + return SimpleNamespace(nodes={}, close=lambda: None) + + monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface) + + mesh._create_serial_interface(" 192.168.50.10 ") + + assert created["hostname"] == "192.168.50.10" + assert created["portNumber"] == mesh._DEFAULT_TCP_PORT + + def test_node_to_dict_handles_nested_structures(mesh_module): mesh = mesh_module