Move to multi-connection modality

This commit is contained in:
Jack Kingsman
2026-02-04 14:22:49 -08:00
committed by jkingsman
parent 64d261e6f9
commit a86d2d7cda
23 changed files with 427 additions and 63 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ export default async function globalSetup(_config: FullConfig) {
if (!res.ok) {
throw new Error(`Health check returned ${res.status}`);
}
const health = (await res.json()) as { radio_connected: boolean; serial_port: string | null };
const health = (await res.json()) as { radio_connected: boolean; connection_info: string | null };
if (!health.radio_connected) {
throw new Error(
@@ -23,7 +23,7 @@ export default async function globalSetup(_config: FullConfig) {
);
}
console.log(`Radio connected on ${health.serial_port}`);
console.log(`Radio connected on ${health.connection_info}`);
return;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
+1 -1
View File
@@ -21,7 +21,7 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
export interface HealthStatus {
radio_connected: boolean;
serial_port: string | null;
connection_info: string | null;
}
export function getHealth(): Promise<HealthStatus> {
+8 -8
View File
@@ -18,7 +18,7 @@ class TestHealthEndpoint:
with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.port = "/dev/ttyUSB0"
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
from app.main import app
@@ -29,7 +29,7 @@ class TestHealthEndpoint:
assert response.status_code == 200
data = response.json()
assert data["radio_connected"] is True
assert data["serial_port"] == "/dev/ttyUSB0"
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
def test_health_disconnected_state(self):
"""Health endpoint reflects disconnected radio."""
@@ -37,7 +37,7 @@ class TestHealthEndpoint:
with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.port = None
mock_rm.connection_info = None
from app.main import app
@@ -48,7 +48,7 @@ class TestHealthEndpoint:
assert response.status_code == 200
data = response.json()
assert data["radio_connected"] is False
assert data["serial_port"] is None
assert data["connection_info"] is None
class TestMessagesEndpoint:
@@ -1252,7 +1252,7 @@ class TestHealthEndpointDatabaseSize:
patch("app.routers.health.os.path.getsize") as mock_getsize,
):
mock_rm.is_connected = True
mock_rm.port = "/dev/ttyUSB0"
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_getsize.return_value = 10 * 1024 * 1024 # 10 MB
from app.main import app
@@ -1282,7 +1282,7 @@ class TestHealthEndpointOldestUndecrypted:
patch("app.routers.health.RawPacketRepository") as mock_repo,
):
mock_rm.is_connected = True
mock_rm.port = "/dev/ttyUSB0"
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_getsize.return_value = 5 * 1024 * 1024 # 5 MB
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=1700000000)
@@ -1309,7 +1309,7 @@ class TestHealthEndpointOldestUndecrypted:
patch("app.routers.health.RawPacketRepository") as mock_repo,
):
mock_rm.is_connected = True
mock_rm.port = "/dev/ttyUSB0"
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_getsize.return_value = 1 * 1024 * 1024 # 1 MB
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
@@ -1336,7 +1336,7 @@ class TestHealthEndpointOldestUndecrypted:
patch("app.routers.health.RawPacketRepository") as mock_repo,
):
mock_rm.is_connected = False
mock_rm.port = None
mock_rm.connection_info = None
mock_getsize.side_effect = OSError("File not found")
mock_repo.get_oldest_undecrypted = AsyncMock(side_effect=RuntimeError("No DB"))
+82
View File
@@ -0,0 +1,82 @@
"""Tests for configuration validation.
These tests verify transport mutual exclusivity, BLE PIN requirement,
and connection_type derivation.
"""
import pytest
from pydantic import ValidationError
from app.config import Settings
class TestTransportExclusivity:
"""Ensure only one transport can be configured at a time."""
def test_no_transport_defaults_to_serial(self):
"""No transport env vars means serial auto-detect."""
s = Settings(serial_port="", tcp_host="", ble_address="")
assert s.connection_type == "serial"
def test_serial_only(self):
s = Settings(serial_port="/dev/ttyUSB0")
assert s.connection_type == "serial"
def test_tcp_only(self):
s = Settings(tcp_host="192.168.1.1")
assert s.connection_type == "tcp"
def test_tcp_with_custom_port(self):
s = Settings(tcp_host="192.168.1.1", tcp_port=5000)
assert s.connection_type == "tcp"
assert s.tcp_port == 5000
def test_tcp_default_port(self):
s = Settings(tcp_host="192.168.1.1")
assert s.tcp_port == 4000
def test_ble_only(self):
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
assert s.connection_type == "ble"
def test_serial_and_tcp_rejected(self):
with pytest.raises(ValidationError, match="Only one transport"):
Settings(serial_port="/dev/ttyUSB0", tcp_host="192.168.1.1")
def test_serial_and_ble_rejected(self):
with pytest.raises(ValidationError, match="Only one transport"):
Settings(
serial_port="/dev/ttyUSB0",
ble_address="AA:BB:CC:DD:EE:FF",
ble_pin="123456",
)
def test_tcp_and_ble_rejected(self):
with pytest.raises(ValidationError, match="Only one transport"):
Settings(
tcp_host="192.168.1.1",
ble_address="AA:BB:CC:DD:EE:FF",
ble_pin="123456",
)
def test_all_three_rejected(self):
with pytest.raises(ValidationError, match="Only one transport"):
Settings(
serial_port="/dev/ttyUSB0",
tcp_host="192.168.1.1",
ble_address="AA:BB:CC:DD:EE:FF",
ble_pin="123456",
)
class TestBLEPinRequirement:
"""BLE address requires a PIN."""
def test_ble_address_without_pin_rejected(self):
with pytest.raises(ValidationError, match="MESHCORE_BLE_PIN is required"):
Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="")
def test_ble_address_with_pin_accepted(self):
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
assert s.ble_address == "AA:BB:CC:DD:EE:FF"
assert s.ble_pin == "123456"
+170
View File
@@ -0,0 +1,170 @@
"""Tests for RadioManager multi-transport connect dispatch.
These tests verify that connect() routes to the correct transport method
based on settings.connection_type, and that connection_info is set correctly.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestRadioManagerConnect:
"""Test that connect() dispatches to the correct transport."""
@pytest.mark.asyncio
async def test_connect_serial_explicit_port(self):
"""Serial connect with explicit port sets connection_info."""
from app.radio import RadioManager
mock_mc = MagicMock()
mock_mc.is_connected = True
with (
patch("app.radio.settings") as mock_settings,
patch("app.radio.MeshCore") as mock_meshcore,
):
mock_settings.connection_type = "serial"
mock_settings.serial_port = "/dev/ttyUSB0"
mock_settings.serial_baudrate = 115200
mock_meshcore.create_serial = AsyncMock(return_value=mock_mc)
rm = RadioManager()
await rm.connect()
mock_meshcore.create_serial.assert_awaited_once_with(
port="/dev/ttyUSB0",
baudrate=115200,
auto_reconnect=True,
max_reconnect_attempts=10,
)
assert rm.connection_info == "Serial: /dev/ttyUSB0"
assert rm.meshcore is mock_mc
@pytest.mark.asyncio
async def test_connect_serial_autodetect(self):
"""Serial connect without port auto-detects."""
from app.radio import RadioManager
mock_mc = MagicMock()
mock_mc.is_connected = True
with (
patch("app.radio.settings") as mock_settings,
patch("app.radio.MeshCore") as mock_meshcore,
patch("app.radio.find_radio_port", new_callable=AsyncMock) as mock_find,
):
mock_settings.connection_type = "serial"
mock_settings.serial_port = ""
mock_settings.serial_baudrate = 115200
mock_find.return_value = "/dev/ttyACM0"
mock_meshcore.create_serial = AsyncMock(return_value=mock_mc)
rm = RadioManager()
await rm.connect()
mock_find.assert_awaited_once_with(115200)
assert rm.connection_info == "Serial: /dev/ttyACM0"
@pytest.mark.asyncio
async def test_connect_serial_autodetect_fails(self):
"""Serial auto-detect raises when no radio found."""
from app.radio import RadioManager
with (
patch("app.radio.settings") as mock_settings,
patch("app.radio.find_radio_port", new_callable=AsyncMock) as mock_find,
):
mock_settings.connection_type = "serial"
mock_settings.serial_port = ""
mock_settings.serial_baudrate = 115200
mock_find.return_value = None
rm = RadioManager()
with pytest.raises(RuntimeError, match="No MeshCore radio found"):
await rm.connect()
@pytest.mark.asyncio
async def test_connect_tcp(self):
"""TCP connect sets connection_info with host:port."""
from app.radio import RadioManager
mock_mc = MagicMock()
mock_mc.is_connected = True
with (
patch("app.radio.settings") as mock_settings,
patch("app.radio.MeshCore") as mock_meshcore,
):
mock_settings.connection_type = "tcp"
mock_settings.tcp_host = "192.168.1.100"
mock_settings.tcp_port = 4000
mock_meshcore.create_tcp = AsyncMock(return_value=mock_mc)
rm = RadioManager()
await rm.connect()
mock_meshcore.create_tcp.assert_awaited_once_with(
host="192.168.1.100",
port=4000,
auto_reconnect=True,
max_reconnect_attempts=10,
)
assert rm.connection_info == "TCP: 192.168.1.100:4000"
assert rm.meshcore is mock_mc
@pytest.mark.asyncio
async def test_connect_ble(self):
"""BLE connect sets connection_info with address."""
from app.radio import RadioManager
mock_mc = MagicMock()
mock_mc.is_connected = True
with (
patch("app.radio.settings") as mock_settings,
patch("app.radio.MeshCore") as mock_meshcore,
):
mock_settings.connection_type = "ble"
mock_settings.ble_address = "AA:BB:CC:DD:EE:FF"
mock_settings.ble_pin = "123456"
mock_meshcore.create_ble = AsyncMock(return_value=mock_mc)
rm = RadioManager()
await rm.connect()
mock_meshcore.create_ble.assert_awaited_once_with(
address="AA:BB:CC:DD:EE:FF",
pin="123456",
auto_reconnect=True,
max_reconnect_attempts=15,
)
assert rm.connection_info == "BLE: AA:BB:CC:DD:EE:FF"
assert rm.meshcore is mock_mc
@pytest.mark.asyncio
async def test_connect_disconnects_existing_first(self):
"""Calling connect() when already connected disconnects first."""
from app.radio import RadioManager
old_mc = MagicMock()
old_mc.disconnect = AsyncMock()
new_mc = MagicMock()
new_mc.is_connected = True
with (
patch("app.radio.settings") as mock_settings,
patch("app.radio.MeshCore") as mock_meshcore,
):
mock_settings.connection_type = "tcp"
mock_settings.tcp_host = "10.0.0.1"
mock_settings.tcp_port = 4000
mock_meshcore.create_tcp = AsyncMock(return_value=new_mc)
rm = RadioManager()
rm._meshcore = old_mc
await rm.connect()
old_mc.disconnect.assert_awaited_once()
assert rm.meshcore is new_mc