feat(transport): MeshCore serial/USB + BLE support + USB auto-detect with stable paths (#52)

MeshCore can now connect over USB serial (and BLE) directly, not just TCP to
the pyMC companion. The meshcore lib already supported create_serial/create_ble;
we just wire it up. Plus a USB auto-detect scanner that resolves stable device
paths to fix ttyACM enumeration hopping across replug/reboot.

Backend:
- ConnectionConfig: meshcore_conn_type (tcp|serial|ble, default tcp),
  meshcore_serial_port, meshcore_baud=115200, meshcore_ble_address (validated)
- meshcore_transport._do_connect dispatches per mode: serial ->
  MeshCore.create_serial(port, baudrate, auto_reconnect, max_reconnect_attempts),
  ble -> create_ble(address or None), tcp -> create_tcp (unchanged). Mode-aware
  logging/reconnect. Transport otherwise unchanged (mode-agnostic once _mc exists).
- factory.meshcore_enabled(config): active when the selected mode is configured
  (serial port / ble address / tcp host); back-compat — meshcore_host + default
  tcp still activates exactly as before.
- serial_ports.list_serial_ports(): pyserial comports + stable_path resolution
  by-id -> by-path -> raw (by-id keyed on USB serial = stable across replug),
  likely_radio flag by VID (RAK/nRF/CP210x/CH340), excludes legacy ttyS*, never
  raises. GET /api/serial-ports (+ container by-id passthrough hint).

Frontend:
- SerialPortPicker component: "Detect USB devices" -> lists ports (likely-radio
  badge, shows stable_path) -> onChange sets the stable by-id path; manual text
  fallback; empty/error/note states.
- MeshCore Connection: type selector TCP/Serial/BLE + per-mode fields (serial
  picker + baud; ble address). Meshtastic serial branch now uses the picker too.

Code-ready; not activated (defaults keep TCP). 35 new tests; suite at 10-failure
baseline. Container needs /dev/serial passed through for by-id paths.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-05 22:12:03 -06:00 committed by GitHub
commit b1ebdd1434
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1023 additions and 40 deletions

View file

@ -0,0 +1,117 @@
import { useState } from 'react'
import { RefreshCw, Radio, Check } from 'lucide-react'
import { getSerialPorts, type SerialPort } from '@/lib/api'
// Reusable USB serial-port picker. Manual text entry is always available; the
// "Detect USB devices" button lists ports from GET /api/serial-ports and, when a
// port is picked, sets the STABLE by-id path (port.stable_path) — that path
// survives ttyACM* enumeration hops, which is the whole point.
export default function SerialPortPicker({
value,
onChange,
label = 'Serial Port',
helper = 'Device path for your USB radio — click Detect to auto-fill a stable by-id path',
}: {
value: string
onChange: (path: string) => void
label?: string
helper?: string
}) {
const [ports, setPorts] = useState<SerialPort[] | null>(null)
const [note, setNote] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const detect = async () => {
setLoading(true)
setError(null)
try {
const res = await getSerialPorts()
setPorts(res.ports)
setNote(res.note || '')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to list serial ports')
setPorts([])
} finally {
setLoading(false)
}
}
return (
<div className="space-y-2">
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<div className="flex gap-2">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/dev/serial/by-id/usb-... (or /dev/ttyACM0)"
className="flex-1 px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600"
/>
<button
type="button"
onClick={detect}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] hover:border-accent disabled:opacity-50 disabled:cursor-not-allowed rounded text-sm text-slate-300 whitespace-nowrap transition-colors"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
{loading ? 'Detecting...' : 'Detect USB devices'}
</button>
</div>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{ports !== null && !error && (
ports.length === 0 ? (
<div className="text-sm text-slate-500 p-3 border border-[#1e2a3a] rounded">
No USB serial devices found is the device passed through to the container?
</div>
) : (
<div className="border border-[#1e2a3a] rounded p-2 space-y-1">
{ports.map((p) => {
const selected = value === p.stable_path
const title = p.product || p.description || p.device
return (
<button
type="button"
key={p.stable_path + p.device}
onClick={() => onChange(p.stable_path)}
className={`w-full text-left flex items-start gap-2 p-2 rounded hover:bg-[#0a0e17] transition-colors ${
selected ? 'bg-[#0a0e17] ring-1 ring-accent' : ''
}`}
>
<div
className={`mt-0.5 w-4 h-4 rounded-full border flex items-center justify-center flex-shrink-0 ${
selected ? 'bg-accent border-accent' : 'border-slate-600'
}`}
>
{selected && <Check size={12} className="text-white" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-200 truncate">{title}</span>
{p.likely_radio && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide bg-accent/15 text-accent border border-accent/30 flex-shrink-0">
<Radio size={10} /> likely radio
</span>
)}
</div>
<div className="text-xs text-slate-500 font-mono truncate">{p.stable_path}</div>
{p.manufacturer && <div className="text-xs text-slate-600 truncate">{p.manufacturer}</div>}
</div>
</button>
)
})}
</div>
)
)}
{note && <p className="text-xs text-slate-600 italic">{note}</p>}
</div>
)
}

View file

@ -239,6 +239,29 @@ async function fetchJson<T>(url: string): Promise<T> {
return response.json()
}
export interface SerialPort {
device: string
by_id?: string | null
by_path?: string | null
stable_path: string
description?: string
vid?: number | null
pid?: number | null
serial_number?: string | null
manufacturer?: string | null
product?: string | null
likely_radio: boolean
}
export interface SerialPortsResponse {
ports: SerialPort[]
note: string
}
export async function getSerialPorts(): Promise<SerialPortsResponse> {
return fetchJson<SerialPortsResponse>('/api/serial-ports')
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}

View file

@ -5,6 +5,7 @@ import { ManagedSecret } from '@/components/ManagedSecret'
import { useDirty } from '@/context/DirtyContext'
import NodePicker from '@/components/NodePicker'
import ChannelPicker from '@/components/ChannelPicker'
import SerialPortPicker from '@/components/SerialPortPicker'
import {
Settings, Bot, MessageSquare, Database, Brain, Eye,
Terminal, Cpu, Cloud, BookOpen, Activity,
@ -739,13 +740,11 @@ export function ConnectionSection({ data, onChange }: { data: ConnectionConfig;
info="Serial: direct USB connection to a Meshtastic radio. TCP: connect over the network to a radio's IP or to meshtasticd running on another machine."
/>
{data.type === 'serial' ? (
<TextInput
<SerialPortPicker
label="Serial Port"
value={data.serial_port}
onChange={(v) => onChange({ ...data, serial_port: v })}
placeholder="/dev/ttyUSB0"
helper="Device path for your USB radio"
info="Usually /dev/ttyUSB0 on Linux or /dev/ttyACM0. Check with 'ls /dev/tty*' after plugging in your radio."
helper="Device path for your USB radio — Detect fills a stable by-id path"
/>
) : (
<div className="grid grid-cols-2 gap-4">

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check, ChevronRight } from 'lucide-react'
import { TextInput, NumberInput, Toggle, ListInput } from './Config'
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
import SerialPortPicker from '@/components/SerialPortPicker'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
@ -18,6 +19,10 @@ interface ConnectionConfig {
meshcore_port?: number
meshcore_auto_reconnect?: boolean
meshcore_max_reconnect_attempts?: number
meshcore_conn_type?: string
meshcore_serial_port?: string
meshcore_baud?: number
meshcore_ble_address?: string
[key: string]: unknown
}
@ -236,17 +241,30 @@ export default function MeshCoreConnection() {
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<p className="text-xs text-slate-500">
Set the host and port to enable MeshCore; leave host blank to disable.
Meshtastic is always active.
Choose how MeshAI reaches your MeshCore node. TCP talks to a companion
frame server; Serial connects to a USB-attached node; BLE pairs over
Bluetooth. Meshtastic is always active.
</p>
<SelectInput
label="Connection Type"
value={config.meshcore_conn_type ?? 'tcp'}
onChange={(v) => upd({ meshcore_conn_type: v })}
options={[
{ value: 'tcp', label: 'TCP (companion)' },
{ value: 'serial', label: 'Serial (USB)' },
{ value: 'ble', label: 'BLE' },
]}
helper="TCP for a companion frame server, Serial for a USB node, BLE for Bluetooth"
/>
{(config.meshcore_conn_type ?? 'tcp') === 'tcp' && (
<div className="grid grid-cols-2 gap-4">
<TextInput
label="MeshCore Host"
value={config.meshcore_host ?? ''}
onChange={(v) => upd({ meshcore_host: v })}
placeholder="192.168.1.100"
helper="IP or hostname — leave blank to disable MeshCore"
info="MeshCore is active when this field is non-empty."
helper="IP or hostname of the companion frame server"
info="The MeshCore companion (frame server) host. Active when non-empty in TCP mode."
/>
<NumberInput
label="MeshCore Port"
@ -257,6 +275,33 @@ export default function MeshCoreConnection() {
helper="MeshCore TCP port (default 5525)"
/>
</div>
)}
{(config.meshcore_conn_type ?? 'tcp') === 'serial' && (
<>
<SerialPortPicker
label="MeshCore Serial Port"
value={config.meshcore_serial_port ?? ''}
onChange={(v) => upd({ meshcore_serial_port: v })}
helper="USB-attached MeshCore node — Detect fills a stable by-id path"
/>
<NumberInput
label="Baud Rate"
value={config.meshcore_baud ?? 115200}
onChange={(v) => upd({ meshcore_baud: v })}
min={1200}
helper="Serial baud rate (default 115200)"
/>
</>
)}
{(config.meshcore_conn_type ?? 'tcp') === 'ble' && (
<TextInput
label="BLE Address"
value={config.meshcore_ble_address ?? ''}
onChange={(v) => upd({ meshcore_ble_address: v })}
placeholder="AA:BB:CC:DD:EE:FF"
helper="Leave blank to scan/pair the first available device"
/>
)}
<div className="pt-2">
<Link
to="/meshtastic/connection"

View file

@ -44,6 +44,18 @@ class ConnectionConfig:
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
meshcore_advert_interval_seconds: int = 10800 # periodic self-advert interval (0 = disabled)
# MeshCore connection type: tcp | serial | ble (default tcp for back-compat)
meshcore_conn_type: str = "tcp"
meshcore_serial_port: str = "" # prefer stable /dev/serial/by-id/... path
meshcore_baud: int = 115200
meshcore_ble_address: str = "" # optional; for ble
def __post_init__(self):
if self.meshcore_conn_type not in {"tcp", "serial", "ble"}:
raise ValueError(
f"meshcore_conn_type must be one of 'tcp', 'serial', 'ble', "
f"got {self.meshcore_conn_type!r}"
)
@dataclass

View file

@ -0,0 +1,23 @@
"""Serial port listing API route."""
from fastapi import APIRouter, Request
from meshai.serial_ports import list_serial_ports, serial_by_id_available
router = APIRouter(tags=["serial-ports"])
@router.get("/serial-ports")
async def get_serial_ports(request: Request):
"""List available USB serial ports with stable path resolution."""
ports = list_serial_ports()
note = (
""
if serial_by_id_available()
else (
"/dev/serial/by-id not available — pass it through to the container "
"(e.g. mount /dev/serial) for stable by-id paths; "
"falling back to raw device paths."
)
)
return {"ports": ports, "note": note}

View file

@ -58,8 +58,10 @@ def create_app() -> FastAPI:
from .api.alert_routes import router as alert_router
from .api.notification_routes import router as notification_router
from .api.debug_routes import router as debug_router
from .api.serial_ports_routes import router as serial_ports_router
app.include_router(system_router, prefix="/api")
app.include_router(serial_ports_router, prefix="/api")
app.include_router(adapter_config_router, prefix="/api")
app.include_router(curation_router, prefix="/api")
app.include_router(config_router, prefix="/api")

122
work/meshai/serial_ports.py Normal file
View file

@ -0,0 +1,122 @@
"""USB serial port scanner for MeshCore connection setup.
Enumerates available serial ports, resolves stable by-id/by-path symlinks,
and flags likely radio devices by USB VID.
"""
import os
import re
from serial.tools.list_ports import comports
# Module-level dir constants — tests monkeypatch these to tmp_path locations.
BY_ID_DIR = "/dev/serial/by-id"
BY_PATH_DIR = "/dev/serial/by-path"
# USB VIDs for known mesh-radio hardware:
# 0x239A Adafruit/RAK nRF52840
# 0x1915 Nordic Semiconductor
# 0x10C4 Silicon Labs CP210x (ESP32 bridge)
# 0x1A86 WCH CH340/CH9102
# 0x55D4 WCH CH9102 (alternate VID)
RADIO_VIDS: frozenset[int] = frozenset({0x239A, 0x1915, 0x10C4, 0x1A86, 0x55D4})
# Pattern for ACM/USB tty devices (not legacy ttyS*)
_ACMUSB_RE = re.compile(r"tty(ACM|USB)\d")
# Pattern for legacy ttyS ports to exclude
_TTYS_RE = re.compile(r"ttyS\d")
def serial_by_id_available() -> bool:
"""Return True when /dev/serial/by-id (or the module constant) exists."""
return os.path.isdir(BY_ID_DIR)
def _build_symlink_map(dirpath: str) -> dict[str, str]:
"""Return a mapping of realpath -> symlink path for all entries in dirpath.
Returns an empty dict if the directory doesn't exist or can't be read.
"""
result: dict[str, str] = {}
try:
entries = os.listdir(dirpath)
except OSError:
return result
for entry in entries:
link = os.path.join(dirpath, entry)
try:
real = os.path.realpath(link)
result[real] = link
except OSError:
pass
return result
def list_serial_ports() -> list[dict]:
"""Enumerate and return usable serial ports.
Includes a port if it has a USB VID/PID OR its device name matches
/dev/tty(ACM|USB). Excludes /dev/ttyS* legacy ports (unless they carry
a vid/pid edge case handled by checking vid presence first).
Each entry dict:
device, by_id, by_path, description, hwid, vid, pid,
serial_number, manufacturer, product, likely_radio, stable_path
Never raises returns [] on unexpected error.
"""
try:
return _scan_ports()
except Exception:
return []
def _scan_ports() -> list[dict]:
by_id_map = _build_symlink_map(BY_ID_DIR)
by_path_map = _build_symlink_map(BY_PATH_DIR)
ports = []
for p in comports():
device: str = p.device or ""
vid: int | None = p.vid
pid: int | None = p.pid
basename = os.path.basename(device)
# Exclude legacy ttyS* UNLESS it has a VID (unlikely but handled correctly)
if _TTYS_RE.match(basename) and vid is None:
continue
# Include: has VID/PID or looks like ACM/USB tty
if vid is None and pid is None and not _ACMUSB_RE.match(basename):
continue
# Resolve stable path via realpath comparison.
real_device = os.path.realpath(device) if device else device
by_id_link: str | None = by_id_map.get(real_device)
by_path_link: str | None = by_path_map.get(real_device)
if by_id_link:
stable_path = by_id_link
elif by_path_link:
stable_path = by_path_link
else:
stable_path = device
likely_radio: bool = vid in RADIO_VIDS if vid is not None else False
ports.append({
"device": device,
"by_id": by_id_link,
"by_path": by_path_link,
"description": getattr(p, "description", "") or "",
"hwid": getattr(p, "hwid", "") or "",
"vid": vid,
"pid": pid,
"serial_number": getattr(p, "serial_number", None),
"manufacturer": getattr(p, "manufacturer", None),
"product": getattr(p, "product", None),
"likely_radio": likely_radio,
"stable_path": stable_path,
})
return ports

View file

@ -3,6 +3,27 @@
from .base import MeshTransport
def meshcore_enabled(config) -> bool:
"""Return True when the selected MeshCore connection mode is configured.
Back-compat: an existing config with ``meshcore_host`` set and the default
``meshcore_conn_type="tcp"`` correctly returns True (tcp branch).
Mode rules:
- tcp meshcore_host is non-empty
- serial meshcore_serial_port is non-empty
- ble meshcore_ble_address is non-empty (address optional for ble scan,
but we require it to be set to prevent accidental activation)
"""
conn_type = (getattr(config, "meshcore_conn_type", "tcp") or "tcp").strip()
if conn_type == "serial":
return bool((getattr(config, "meshcore_serial_port", "") or "").strip())
if conn_type == "ble":
return bool((getattr(config, "meshcore_ble_address", "") or "").strip())
# tcp (default)
return bool((getattr(config, "meshcore_host", "") or "").strip())
def build_transport(config, meshcore_context=None) -> MeshTransport:
"""Instantiate and return the active MeshTransport derived from config.
@ -10,9 +31,10 @@ def build_transport(config, meshcore_context=None) -> MeshTransport:
separate mode flag:
- Meshtastic is always the base transport.
- MeshCore is active when ``config.meshcore_host`` is a non-empty string.
- MeshCore is active when the selected mode's field is configured
(see ``meshcore_enabled``).
- Both configured CompositeTransport wrapping both.
- MeshCore host blank (default) Meshtastic only.
- MeshCore not configured Meshtastic only.
Args:
config: A ConnectionConfig (or duck-compatible object).
@ -25,8 +47,7 @@ def build_transport(config, meshcore_context=None) -> MeshTransport:
from meshai.connector import MeshtasticTransport
meshtastic = MeshtasticTransport(config)
meshcore_host = getattr(config, "meshcore_host", "") or ""
if meshcore_host.strip():
if meshcore_enabled(config):
from meshai.transport.meshcore_transport import MeshCoreTransport
from meshai.transport.composite_transport import CompositeTransport
return CompositeTransport(

View file

@ -391,10 +391,31 @@ class MeshCoreTransport(MeshTransport):
# Internal coroutines (run on the dedicated loop)
# ------------------------------------------------------------------
async def _do_connect(self, host: str, port: int,
auto_reconnect: bool, max_attempts: int):
"""Lazy-import meshcore and create the TCP client."""
async def _do_connect(self, conn_type: str, target: str,
auto_reconnect: bool, max_attempts: int,
host: str = "", port: int = 5050,
serial_port: str = "", baud: int = 115200,
ble_address: str = ""):
"""Lazy-import meshcore and create the appropriate client.
``conn_type`` selects tcp | serial | ble. ``target`` is a human-readable
string used only for logging (built in connect()).
"""
from meshcore import MeshCore # noqa: PLC0415 (lazy import intentional)
if conn_type == "serial":
mc = await MeshCore.create_serial(
serial_port,
baudrate=baud,
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_attempts,
)
elif conn_type == "ble":
mc = await MeshCore.create_ble(
address=(ble_address or None),
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_attempts,
)
else: # tcp (default)
mc = await MeshCore.create_tcp(
host, port,
auto_reconnect=auto_reconnect,
@ -437,13 +458,25 @@ class MeshCoreTransport(MeshTransport):
# ------------------------------------------------------------------
def connect(self) -> None:
"""Connect to the pyMC companion TCP frame server."""
host = getattr(self.config, "meshcore_host", "100.64.0.9")
"""Connect to the MeshCore companion (tcp, serial, or ble)."""
conn_type = getattr(self.config, "meshcore_conn_type", "tcp") or "tcp"
host = getattr(self.config, "meshcore_host", "")
port = getattr(self.config, "meshcore_port", 5050)
serial_port = getattr(self.config, "meshcore_serial_port", "")
baud = getattr(self.config, "meshcore_baud", 115200)
ble_address = getattr(self.config, "meshcore_ble_address", "")
auto_reconnect = getattr(self.config, "meshcore_auto_reconnect", True)
max_attempts = getattr(self.config, "meshcore_max_reconnect_attempts", 5)
logger.info("MeshCoreTransport: connecting to %s:%d", host, port)
# Build a human-readable target string for logging.
if conn_type == "serial":
target = f"serial:{serial_port}@{baud}"
elif conn_type == "ble":
target = f"ble:{ble_address or 'auto'}"
else:
target = f"{host}:{port}"
logger.info("MeshCoreTransport: connecting to %s", target)
# Start the dedicated event loop in a daemon thread.
self._loop = asyncio.new_event_loop()
@ -464,7 +497,13 @@ class MeshCoreTransport(MeshTransport):
try:
mc = self._run_coro(
self._do_connect(host, port, auto_reconnect, max_attempts),
self._do_connect(
conn_type, target,
auto_reconnect, max_attempts,
host=host, port=port,
serial_port=serial_port, baud=baud,
ble_address=ble_address,
),
timeout=30.0,
)
except Exception as exc:
@ -475,7 +514,7 @@ class MeshCoreTransport(MeshTransport):
if mc is None:
self._stop_loop()
raise RuntimeError(
f"MeshCore.create_tcp({host}:{port}) returned None — connection failed"
f"MeshCore connect ({target}) returned None — connection failed"
)
self._mc = mc

View file

@ -0,0 +1,327 @@
"""Tests for MeshCore multi-transport connection type support.
Covers:
- ConnectionConfig validation of meshcore_conn_type
- meshcore_enabled() factory helper
- _do_connect() selects the right MeshCore.create_* per mode
"""
import asyncio
import sys
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
# ---------------------------------------------------------------------------
# Build a recording fake meshcore module
# ---------------------------------------------------------------------------
def _build_recording_meshcore():
"""Build a fake meshcore module that records which create_* was called."""
mod = types.ModuleType("meshcore")
class EventType:
CONTACT_MSG_RECV = "CONTACT_MSG_RECV"
CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV"
DISCONNECTED = "DISCONNECTED"
CONNECTED = "CONNECTED"
ACK = "ACK"
mod.EventType = EventType
class _FakeMeshCore:
# Shared call log: list of (method_name, args, kwargs)
_calls: list = []
self_info = {"public_key": "aabbccdd1122", "name": "FakeNode"}
contacts = {}
async def start_auto_message_fetching(self):
pass
async def stop_auto_message_fetching(self):
pass
async def disconnect(self):
pass
async def ensure_contacts(self, follow=False):
return True
def subscribe(self, event_type, callback):
pass
def get_contact_by_key_prefix(self, prefix):
return None
@classmethod
async def create_tcp(cls, host, port,
auto_reconnect=True, max_reconnect_attempts=5,
debug=False, only_error=False, default_timeout=None):
cls._calls.append(("create_tcp", (host, port), {
"auto_reconnect": auto_reconnect,
"max_reconnect_attempts": max_reconnect_attempts,
}))
return cls()
@classmethod
async def create_serial(cls, port, baudrate=115200,
auto_reconnect=True, max_reconnect_attempts=5,
debug=False, only_error=False, default_timeout=None,
cx_dly=0.1):
cls._calls.append(("create_serial", (port,), {
"baudrate": baudrate,
"auto_reconnect": auto_reconnect,
"max_reconnect_attempts": max_reconnect_attempts,
}))
return cls()
@classmethod
async def create_ble(cls, address=None, client=None, device=None, pin=None,
debug=False, only_error=False, default_timeout=None,
auto_reconnect=True, max_reconnect_attempts=5):
cls._calls.append(("create_ble", (), {
"address": address,
"auto_reconnect": auto_reconnect,
"max_reconnect_attempts": max_reconnect_attempts,
}))
return cls()
class commands:
@staticmethod
async def send_chan_msg(chan_idx, text):
result = MagicMock()
result.is_error.return_value = False
return result
@staticmethod
async def send_msg(dst, text):
result = MagicMock()
result.is_error.return_value = False
return result
@staticmethod
async def send_advert(flood=False):
pass
mod.MeshCore = _FakeMeshCore
return mod
# Register before production imports so lazy-import finds the mock.
# Use a unique key so we don't clobber test_meshcore_transport.py's module.
_fake_mc_mod = _build_recording_meshcore()
sys.modules.setdefault("meshcore", _fake_mc_mod)
# ---------------------------------------------------------------------------
# Production imports (after mock is registered)
# ---------------------------------------------------------------------------
from meshai.config import ConnectionConfig # noqa: E402
from meshai.transport.factory import build_transport, meshcore_enabled # noqa: E402
from meshai.transport.meshcore_transport import MeshCoreTransport # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_fake_mc():
"""Return the _FakeMeshCore class (reachable via sys.modules)."""
return sys.modules["meshcore"].MeshCore
def _clear_calls():
_get_fake_mc()._calls.clear()
def _run(coro):
"""Run a coroutine in a fresh event loop."""
return asyncio.run(coro)
# ---------------------------------------------------------------------------
# Part 1: ConnectionConfig validation
# ---------------------------------------------------------------------------
class TestConnectionConfigValidation:
def test_invalid_conn_type_raises(self):
with pytest.raises(ValueError, match="meshcore_conn_type"):
ConnectionConfig(meshcore_conn_type="bogus")
def test_tcp_accepted(self):
cfg = ConnectionConfig(meshcore_conn_type="tcp")
assert cfg.meshcore_conn_type == "tcp"
def test_serial_accepted(self):
cfg = ConnectionConfig(meshcore_conn_type="serial")
assert cfg.meshcore_conn_type == "serial"
def test_ble_accepted(self):
cfg = ConnectionConfig(meshcore_conn_type="ble")
assert cfg.meshcore_conn_type == "ble"
def test_default_is_tcp(self):
cfg = ConnectionConfig()
assert cfg.meshcore_conn_type == "tcp"
def test_new_fields_have_defaults(self):
cfg = ConnectionConfig()
assert cfg.meshcore_serial_port == ""
assert cfg.meshcore_baud == 115200
assert cfg.meshcore_ble_address == ""
# ---------------------------------------------------------------------------
# Part 2: meshcore_enabled() factory helper
# ---------------------------------------------------------------------------
class TestMeshcoreEnabled:
def test_tcp_with_host_true(self):
cfg = ConnectionConfig(meshcore_conn_type="tcp", meshcore_host="127.0.0.1")
assert meshcore_enabled(cfg) is True
def test_tcp_without_host_false(self):
cfg = ConnectionConfig(meshcore_conn_type="tcp", meshcore_host="")
assert meshcore_enabled(cfg) is False
def test_serial_with_port_true(self):
cfg = ConnectionConfig(meshcore_conn_type="serial", meshcore_serial_port="/dev/ttyACM0")
assert meshcore_enabled(cfg) is True
def test_serial_without_port_false(self):
cfg = ConnectionConfig(meshcore_conn_type="serial", meshcore_serial_port="")
assert meshcore_enabled(cfg) is False
def test_serial_ignores_host(self):
"""serial mode: having meshcore_host set does NOT enable MeshCore."""
cfg = ConnectionConfig(
meshcore_conn_type="serial",
meshcore_host="127.0.0.1",
meshcore_serial_port="",
)
assert meshcore_enabled(cfg) is False
def test_ble_with_address_true(self):
cfg = ConnectionConfig(meshcore_conn_type="ble", meshcore_ble_address="AA:BB:CC:DD:EE:FF")
assert meshcore_enabled(cfg) is True
def test_ble_without_address_false(self):
cfg = ConnectionConfig(meshcore_conn_type="ble", meshcore_ble_address="")
assert meshcore_enabled(cfg) is False
def test_back_compat_tcp_default(self):
"""Existing config: meshcore_host set, default conn_type='tcp' → True."""
cfg = ConnectionConfig(meshcore_host="100.64.0.9")
# conn_type defaults to "tcp"
assert cfg.meshcore_conn_type == "tcp"
assert meshcore_enabled(cfg) is True
def test_whitespace_only_host_false(self):
cfg = ConnectionConfig(meshcore_conn_type="tcp", meshcore_host=" ")
assert meshcore_enabled(cfg) is False
# ---------------------------------------------------------------------------
# Part 3: _do_connect selects correct create_* method
# ---------------------------------------------------------------------------
class TestDoConnectSelection:
"""Call _do_connect directly (it's just an async method) via asyncio.run."""
def setup_method(self):
_clear_calls()
def _make_transport(self, **cfg_kwargs):
cfg = ConnectionConfig(**cfg_kwargs)
return MeshCoreTransport(cfg), cfg
def test_tcp_calls_create_tcp(self):
t, cfg = self._make_transport(
meshcore_conn_type="tcp",
meshcore_host="127.0.0.1",
meshcore_port=5050,
meshcore_auto_reconnect=True,
meshcore_max_reconnect_attempts=3,
)
_run(t._do_connect(
"tcp", "127.0.0.1:5050",
True, 3,
host="127.0.0.1", port=5050,
serial_port="", baud=115200,
ble_address="",
))
calls = _get_fake_mc()._calls
assert len(calls) == 1
name, args, kwargs = calls[0]
assert name == "create_tcp"
assert args == ("127.0.0.1", 5050)
assert kwargs["auto_reconnect"] is True
assert kwargs["max_reconnect_attempts"] == 3
def test_serial_calls_create_serial(self):
t, cfg = self._make_transport(
meshcore_conn_type="serial",
meshcore_serial_port="/dev/ttyACM0",
meshcore_baud=115200,
meshcore_auto_reconnect=True,
meshcore_max_reconnect_attempts=5,
)
_run(t._do_connect(
"serial", "serial:/dev/ttyACM0@115200",
True, 5,
serial_port="/dev/ttyACM0", baud=115200,
))
calls = _get_fake_mc()._calls
assert len(calls) == 1
name, args, kwargs = calls[0]
assert name == "create_serial"
assert args == ("/dev/ttyACM0",)
assert kwargs["baudrate"] == 115200
assert kwargs["auto_reconnect"] is True
assert kwargs["max_reconnect_attempts"] == 5
def test_serial_does_not_pass_cx_dly(self):
"""create_serial should use default cx_dly (not passed explicitly)."""
t, _ = self._make_transport(meshcore_conn_type="serial")
_run(t._do_connect(
"serial", "serial:/dev/ttyACM0@115200",
True, 5,
serial_port="/dev/ttyACM0", baud=115200,
))
calls = _get_fake_mc()._calls
assert "cx_dly" not in calls[0][2]
def test_ble_calls_create_ble(self):
t, _ = self._make_transport(
meshcore_conn_type="ble",
meshcore_ble_address="AA:BB:CC:DD:EE:FF",
meshcore_auto_reconnect=False,
meshcore_max_reconnect_attempts=2,
)
_run(t._do_connect(
"ble", "ble:AA:BB:CC:DD:EE:FF",
False, 2,
ble_address="AA:BB:CC:DD:EE:FF",
))
calls = _get_fake_mc()._calls
assert len(calls) == 1
name, args, kwargs = calls[0]
assert name == "create_ble"
assert kwargs["address"] == "AA:BB:CC:DD:EE:FF"
assert kwargs["auto_reconnect"] is False
assert kwargs["max_reconnect_attempts"] == 2
def test_ble_empty_address_passes_none(self):
"""Empty ble_address → address=None (scan for any device)."""
t, _ = self._make_transport(meshcore_conn_type="ble")
_run(t._do_connect(
"ble", "ble:auto",
True, 5,
ble_address="",
))
calls = _get_fake_mc()._calls
assert calls[0][2]["address"] is None

View file

@ -0,0 +1,253 @@
"""Tests for meshai.serial_ports — USB serial port scanner.
All tests are hermetic: no real /dev access, no real pyserial comports call.
Fake comport objects use types.SimpleNamespace.
"""
import os
import types
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_port(
device,
vid=None,
pid=None,
description="Test Device",
hwid="USB",
serial_number="SN001",
manufacturer="Acme",
product="Widget",
):
return types.SimpleNamespace(
device=device,
vid=vid,
pid=pid,
description=description,
hwid=hwid,
serial_number=serial_number,
manufacturer=manufacturer,
product=product,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_module_cache():
"""Ensure meshai.serial_ports is re-imported fresh for each test when
BY_ID_DIR/BY_PATH_DIR are monkeypatched."""
yield
# ---------------------------------------------------------------------------
# (a) stable_path resolves to by-id link when present
# ---------------------------------------------------------------------------
def test_stable_path_by_id(tmp_path, monkeypatch):
import meshai.serial_ports as sp
# Create a fake device file and a by-id symlink pointing to it.
fake_device = tmp_path / "ttyACM0"
fake_device.write_text("")
by_id_dir = tmp_path / "by-id"
by_id_dir.mkdir()
link = by_id_dir / "usb-RAK-nRF52840_ABC123-if00"
link.symlink_to(fake_device)
monkeypatch.setattr(sp, "BY_ID_DIR", str(by_id_dir))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(tmp_path / "by-path-nonexistent"))
port = _fake_port(str(fake_device), vid=0x239A, pid=0x0001)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
assert result[0]["by_id"] == str(link)
assert result[0]["stable_path"] == str(link)
assert result[0]["by_path"] is None
# ---------------------------------------------------------------------------
# (b) falls back to by-path when only by-path matches
# ---------------------------------------------------------------------------
def test_stable_path_by_path_fallback(tmp_path, monkeypatch):
import meshai.serial_ports as sp
fake_device = tmp_path / "ttyACM0"
fake_device.write_text("")
by_path_dir = tmp_path / "by-path"
by_path_dir.mkdir()
link = by_path_dir / "platform-fd500000.pcie-pci-0000:01:00.0-usb-0:1.1:1.0"
link.symlink_to(fake_device)
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path / "by-id-nonexistent"))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(by_path_dir))
port = _fake_port(str(fake_device), vid=0x239A, pid=0x0001)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
assert result[0]["by_id"] is None
assert result[0]["by_path"] == str(link)
assert result[0]["stable_path"] == str(link)
# ---------------------------------------------------------------------------
# (c) falls back to raw device when neither dir has a match
# ---------------------------------------------------------------------------
def test_stable_path_raw_device_fallback(tmp_path, monkeypatch):
import meshai.serial_ports as sp
fake_device = tmp_path / "ttyACM0"
fake_device.write_text("")
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path / "by-id-missing"))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(tmp_path / "by-path-missing"))
port = _fake_port(str(fake_device), vid=0x239A, pid=0x0001)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
assert result[0]["by_id"] is None
assert result[0]["by_path"] is None
assert result[0]["stable_path"] == str(fake_device)
# ---------------------------------------------------------------------------
# (d) likely_radio True for known VID, False for unknown
# ---------------------------------------------------------------------------
def test_likely_radio_known_vid(tmp_path, monkeypatch):
import meshai.serial_ports as sp
fake_device = tmp_path / "ttyACM0"
fake_device.write_text("")
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path / "noid"))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(tmp_path / "nopath"))
port = _fake_port(str(fake_device), vid=0x239A, pid=0x0001)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
assert result[0]["likely_radio"] is True
def test_likely_radio_unknown_vid(tmp_path, monkeypatch):
import meshai.serial_ports as sp
fake_device = tmp_path / "ttyACM1"
fake_device.write_text("")
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path / "noid"))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(tmp_path / "nopath"))
port = _fake_port(str(fake_device), vid=0x1234, pid=0x5678)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
assert result[0]["likely_radio"] is False
# ---------------------------------------------------------------------------
# (e) ttyS* legacy port is excluded (no vid/pid)
# ---------------------------------------------------------------------------
def test_ttys_legacy_excluded(tmp_path, monkeypatch):
import meshai.serial_ports as sp
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path / "noid"))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(tmp_path / "nopath"))
legacy = _fake_port("/dev/ttyS0", vid=None, pid=None)
monkeypatch.setattr(sp, "comports", lambda: [legacy])
result = sp.list_serial_ports()
assert result == []
def test_ttys_with_vid_included(tmp_path, monkeypatch):
"""Edge case: a ttyS* port WITH a vid/pid should still be included."""
import meshai.serial_ports as sp
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path / "noid"))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(tmp_path / "nopath"))
# ttyS* + vid → NOT excluded by the legacy rule (the rule only applies when vid is None)
port = _fake_port("/dev/ttyS0", vid=0x10C4, pid=0xEA60)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
# ---------------------------------------------------------------------------
# (f) missing by-id / by-path dirs → no raise, stable_path == device
# ---------------------------------------------------------------------------
def test_missing_serial_dirs_no_raise(tmp_path, monkeypatch):
import meshai.serial_ports as sp
# Both dirs are nonexistent paths — should not raise.
monkeypatch.setattr(sp, "BY_ID_DIR", "/does/not/exist/by-id")
monkeypatch.setattr(sp, "BY_PATH_DIR", "/does/not/exist/by-path")
port = _fake_port("/dev/ttyACM0", vid=0x1915, pid=0x520F)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
assert result[0]["by_id"] is None
assert result[0]["by_path"] is None
assert result[0]["stable_path"] == "/dev/ttyACM0"
# ---------------------------------------------------------------------------
# Extra: multiple VIDs from RADIO_VIDS set
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("vid", [0x239A, 0x1915, 0x10C4, 0x1A86, 0x55D4])
def test_all_radio_vids_flagged(tmp_path, monkeypatch, vid):
import meshai.serial_ports as sp
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path / "noid"))
monkeypatch.setattr(sp, "BY_PATH_DIR", str(tmp_path / "nopath"))
port = _fake_port("/dev/ttyACM0", vid=vid, pid=0x0001)
monkeypatch.setattr(sp, "comports", lambda: [port])
result = sp.list_serial_ports()
assert len(result) == 1
assert result[0]["likely_radio"] is True
# ---------------------------------------------------------------------------
# serial_by_id_available
# ---------------------------------------------------------------------------
def test_serial_by_id_available_true(tmp_path, monkeypatch):
import meshai.serial_ports as sp
monkeypatch.setattr(sp, "BY_ID_DIR", str(tmp_path))
assert sp.serial_by_id_available() is True
def test_serial_by_id_available_false(monkeypatch):
import meshai.serial_ports as sp
monkeypatch.setattr(sp, "BY_ID_DIR", "/does/not/exist/by-id")
assert sp.serial_by_id_available() is False