Files
meshtastic_firmware/mcp-server/src/meshtastic_mcp/devices.py
T
Ben MeadorsandGitHub 21cef8c2e5 Add TCP support for Meshtastic MCP interface / tests and update docs (#10355)
* Add TCP support for Meshtastic MCP interface / tests and update docs

* Address TCP endpoint validation and error handling in connection

* TCP connection handling and device listing logic

* Fix docstring formatting in normalize_tcp_endpoint function
2026-04-30 13:51:29 -05:00

136 lines
5.1 KiB
Python

"""USB/serial + TCP device discovery.
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
a Meshtastic device" signal.
If `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` is set, a synthetic entry for the
`meshtasticd` daemon at that endpoint is prepended to the result, so
`resolve_port(None)` auto-selects it like a USB candidate.
"""
from __future__ import annotations
import os
from typing import Any
from serial.tools import list_ports
def _to_hex(value: int | None) -> str | None:
if value is None:
return None
return f"0x{value:04x}"
def _tcp_endpoint_from_env() -> dict[str, Any] | None:
"""Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set.
If the env var is malformed (non-integer port, path-like host, etc.),
return an entry with `likely_meshtastic=False` and the parser error in
the description, rather than raising — `list_devices` is the diagnostic
tool a user reaches for when their env var isn't working, so it must
not crash on misconfiguration.
"""
host = os.environ.get("MESHTASTIC_MCP_TCP_HOST")
if not host:
return None
# Lazy import to avoid a circular dependency (connection imports devices).
from . import connection
try:
port = connection.normalize_tcp_endpoint(host)
description = "meshtasticd (TCP)"
likely = True
except connection.ConnectionError as e:
# Surface the raw env-var value plus the parser's reason so the
# user can see exactly what they set and why it was rejected.
# Don't double the scheme if the user already prefixed `tcp://`.
port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}"
description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}"
likely = False
return {
"port": port,
"vid": None,
"pid": None,
"description": description,
"manufacturer": None,
"product": None,
"serial_number": None,
"likely_meshtastic": likely,
"blacklisted": False,
}
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
"""Return enriched info for serial ports, flagging Meshtastic candidates.
`likely_meshtastic` is True when the port's USB VID matches the Meshtastic
allowlist (`0x239a` Adafruit/RAK, `0x303a` Espressif). When no allowlisted
ports are present, ports whose VID is NOT in the blocklist (J-Link, ST-LINK,
PPK2, etc.) are surfaced as `likely_meshtastic=False` candidates.
With `include_unknown=False` (default), we return only ports that are
plausibly Meshtastic. With `include_unknown=True`, every serial port the
OS knows about is returned (useful for debugging "why isn't my board
detected").
"""
# Import lazily so the module loads even without the `meshtastic` package
# (useful for introspection / schema generation).
from meshtastic import util as mt_util # type: ignore[import-untyped]
meshtastic_ports: set[str] = set(mt_util.findPorts(eliminate_duplicates=True))
whitelist = getattr(mt_util, "whitelistVids", {})
blacklist = getattr(mt_util, "blacklistVids", {})
results: list[dict[str, Any]] = []
for info in list_ports.comports():
port_path = info.device
vid = info.vid
in_whitelist = vid is not None and vid in whitelist
in_blacklist = vid is not None and vid in blacklist
likely = port_path in meshtastic_ports and in_whitelist
# If no allowlisted ports were found, findPorts falls back to
# everything-not-in-blacklist; treat those as plausible candidates
# but not "likely".
fallback_candidate = port_path in meshtastic_ports and not in_whitelist
if not likely and not fallback_candidate and not include_unknown:
continue
results.append(
{
"port": port_path,
"vid": _to_hex(vid),
"pid": _to_hex(info.pid),
"description": info.description or None,
"manufacturer": info.manufacturer or None,
"product": info.product or None,
"serial_number": info.serial_number or None,
"likely_meshtastic": likely,
"blacklisted": in_blacklist,
}
)
# Append the TCP endpoint (if env var set) and sort everything together.
tcp_entry = _tcp_endpoint_from_env()
if tcp_entry is not None:
results.append(tcp_entry)
# Stable ordering: likely_meshtastic first; within rank, TCP wins over
# USB (explicit env-var configuration takes precedence over USB
# enumeration); then by port path. A misconfigured TCP entry has
# likely_meshtastic=False and lands among the other ignored entries —
# it does NOT pre-empt real USB devices at the top of the list.
results.sort(
key=lambda r: (
not r["likely_meshtastic"],
not r["port"].startswith("tcp://"),
r["port"],
)
)
return results