meshai/work/tests/test_serial_ports.py
malice b1ebdd1434
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>
2026-07-05 22:12:03 -06:00

253 lines
8.5 KiB
Python

"""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