diff --git a/AGENTS.md b/AGENTS.md index 824cfa6..9bdc548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ npm run build # run a frontend build ## Overview -A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over serial and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration. +A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over Serial, TCP, or BLE and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration. **For detailed component documentation, see:** - `app/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption) @@ -54,7 +54,7 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh │ │ RadioManager + Event Handlers │ │ │ └──────────────────────────────────────────────────────────┘ │ └───────────────────────────┼──────────────────────────────────────┘ - │ Serial + │ Serial / TCP / BLE ┌──────┴──────┐ │ MeshCore │ │ Radio │ @@ -308,5 +308,11 @@ mc.subscribe(EventType.ACK, handler) | Variable | Default | Description | |----------|---------|-------------| | `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio | +| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) | +| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) | +| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) | +| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code | | `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location | | `MESHCORE_MAX_RADIO_CONTACTS` | `200` | Max recent contacts to keep on radio for DM ACKs | + +**Transport mutual exclusivity:** Only one of `MESHCORE_SERIAL_PORT`, `MESHCORE_TCP_HOST`, or `MESHCORE_BLE_ADDRESS` may be set. If none are set, serial auto-detection is used. diff --git a/README.md b/README.md index 4166e8f..47c7bc2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RemoteTerm for MeshCore -Backend server + browser interface for MeshCore mesh radio networks. Attach your radio over serial, and then you can: +Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can: * Send and receive DMs and GroupTexts * Cache all received packets, decrypting as you gain keys @@ -23,9 +23,11 @@ If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./ - Python 3.10+ - Node.js 18+ (for frontend development only) - [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` -- MeshCore radio connected via USB serial +- MeshCore radio connected via USB serial, TCP, or BLE + + +Finding your serial port -**Find your serial port:** ```bash ####### # Linux @@ -50,8 +52,8 @@ usbipd list # attach device to WSL usbipd bind --busid 3-8 - ``` + ## Quick Start @@ -71,9 +73,16 @@ cd frontend && npm install && npm run build && cd .. uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` -The server auto-detects the serial port. To specify manually: +The server auto-detects the serial port. To specify a transport manually: ```bash +# Serial (explicit port) MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 + +# TCP (e.g. via wifi-enabled firmware) +MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 + +# BLE (address and PIN both required) +MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` Access at http://localhost:8000 @@ -84,12 +93,23 @@ Access at http://localhost:8000 > **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable. +> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work. + ```bash +# Serial docker run -d \ --device=/dev/ttyUSB0 \ -v remoteterm-data:/app/data \ -p 8000:8000 \ jkingsman/remote-terminal-for-meshcore:latest + +# TCP +docker run -d \ + -e MESHCORE_TCP_HOST=192.168.1.100 \ + -e MESHCORE_TCP_PORT=4000 \ + -v remoteterm-data:/app/data \ + -p 8000:8000 \ + jkingsman/remote-terminal-for-meshcore:latest ``` ## Development @@ -143,11 +163,17 @@ npm run build # build the frontend | Variable | Default | Description | |----------|---------|-------------| | `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path | -| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Baud rate | +| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate | +| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) | +| `MESHCORE_TCP_PORT` | 4000 | TCP port | +| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) | +| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) | | `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR | | `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path | | `MESHCORE_MAX_RADIO_CONTACTS` | 200 | Max recent contacts to keep on radio for DM ACKs | +Only one transport may be active at a time. If multiple are set, the server will refuse to start. + ## Additional Setup diff --git a/app/AGENTS.md b/app/AGENTS.md index 4ae1d65..538f264 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -62,7 +62,7 @@ await AppSettingsRepository.add_favorite("contact", public_key) ### Radio Connection -`RadioManager` in `radio.py` handles serial connection: +`RadioManager` in `radio.py` handles radio connection over Serial, TCP, or BLE: ```python from app.radio import radio_manager @@ -70,9 +70,12 @@ from app.radio import radio_manager # Access meshcore instance if radio_manager.meshcore: await radio_manager.meshcore.commands.send_msg(dst, msg) + +# Check connection info (e.g. "Serial: /dev/ttyUSB0", "TCP: 192.168.1.1:4000", "BLE: AA:BB:CC:DD:EE:FF") +print(radio_manager.connection_info) ``` -Auto-detection scans common serial ports when `MESHCORE_SERIAL_PORT` is not set. +Transport is configured via env vars (see root AGENTS.md). When no transport env vars are set, serial auto-detection is used. ### Event-Driven Architecture @@ -116,7 +119,7 @@ from app.websocket import broadcast_error, broadcast_health broadcast_error("Operation failed", "Additional details") # Notify clients of connection status change -broadcast_health(radio_connected=True, serial_port="/dev/ttyUSB0") +broadcast_health(radio_connected=True, connection_info="Serial: /dev/ttyUSB0") ``` ### Connection Monitoring @@ -464,7 +467,7 @@ result = await sync_recent_contacts_to_radio(force=True) All endpoints are prefixed with `/api`. ### Health -- `GET /api/health` - Connection status, serial port +- `GET /api/health` - Connection status, connection info ### Radio - `GET /api/radio/config` - Read config (public key, name, radio params) diff --git a/app/config.py b/app/config.py index 4213d91..1ef9197 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,7 @@ import logging from typing import Literal +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -9,9 +10,39 @@ class Settings(BaseSettings): serial_port: str = "" # Empty string triggers auto-detection serial_baudrate: int = 115200 + tcp_host: str = "" + tcp_port: int = 4000 + ble_address: str = "" + ble_pin: str = "" log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" database_path: str = "data/meshcore.db" + @model_validator(mode="after") + def validate_transport_exclusivity(self) -> "Settings": + transports_set = sum( + [ + bool(self.serial_port), + bool(self.tcp_host), + bool(self.ble_address), + ] + ) + if transports_set > 1: + raise ValueError( + "Only one transport may be configured at a time. " + "Set exactly one of MESHCORE_SERIAL_PORT, MESHCORE_TCP_HOST, or MESHCORE_BLE_ADDRESS." + ) + if self.ble_address and not self.ble_pin: + raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.") + return self + + @property + def connection_type(self) -> Literal["serial", "tcp", "ble"]: + if self.tcp_host: + return "tcp" + if self.ble_address: + return "ble" + return "serial" + settings = Settings() diff --git a/app/radio.py b/app/radio.py index f0ff10b..9d6599f 100644 --- a/app/radio.py +++ b/app/radio.py @@ -102,7 +102,7 @@ class RadioManager: def __init__(self): self._meshcore: MeshCore | None = None - self._port: str | None = None + self._connection_info: str | None = None self._reconnect_task: asyncio.Task | None = None self._last_connected: bool = False self._reconnect_lock: asyncio.Lock | None = None @@ -169,8 +169,8 @@ class RadioManager: return self._meshcore @property - def port(self) -> str | None: - return self._port + def connection_info(self) -> str | None: + return self._connection_info @property def is_connected(self) -> bool: @@ -181,10 +181,20 @@ class RadioManager: return self._reconnect_lock is not None and self._reconnect_lock.locked() async def connect(self) -> None: - """Connect to the radio over serial.""" + """Connect to the radio using the configured transport.""" if self._meshcore is not None: await self.disconnect() + connection_type = settings.connection_type + if connection_type == "tcp": + await self._connect_tcp() + elif connection_type == "ble": + await self._connect_ble() + else: + await self._connect_serial() + + async def _connect_serial(self) -> None: + """Connect to the radio over serial.""" port = settings.serial_port # Auto-detect if no port specified @@ -205,10 +215,42 @@ class RadioManager: auto_reconnect=True, max_reconnect_attempts=10, ) - self._port = port + self._connection_info = f"Serial: {port}" self._last_connected = True logger.debug("Serial connection established") + async def _connect_tcp(self) -> None: + """Connect to the radio over TCP.""" + host = settings.tcp_host + port = settings.tcp_port + + logger.debug("Connecting to radio at %s:%d (TCP)", host, port) + self._meshcore = await MeshCore.create_tcp( + host=host, + port=port, + auto_reconnect=True, + max_reconnect_attempts=10, + ) + self._connection_info = f"TCP: {host}:{port}" + self._last_connected = True + logger.debug("TCP connection established") + + async def _connect_ble(self) -> None: + """Connect to the radio over BLE.""" + address = settings.ble_address + pin = settings.ble_pin + + logger.debug("Connecting to radio at %s (BLE)", address) + self._meshcore = await MeshCore.create_ble( + address=address, + pin=pin, + auto_reconnect=True, + max_reconnect_attempts=15, + ) + self._connection_info = f"BLE: {address}" + self._last_connected = True + logger.debug("BLE connection established") + async def disconnect(self) -> None: """Disconnect from the radio.""" if self._meshcore is not None: @@ -250,8 +292,8 @@ class RadioManager: await self.connect() if self.is_connected: - logger.info("Radio reconnected successfully at %s", self._port) - broadcast_health(True, self._port) + logger.info("Radio reconnected successfully at %s", self._connection_info) + broadcast_health(True, self._connection_info) return True else: logger.warning("Reconnection failed: not connected after connect()") @@ -280,7 +322,7 @@ class RadioManager: if self._last_connected and not current_connected: # Connection lost logger.warning("Radio connection lost, broadcasting status change") - broadcast_health(False, self._port) + broadcast_health(False, self._connection_info) self._last_connected = False # Attempt reconnection @@ -291,7 +333,7 @@ class RadioManager: elif not self._last_connected and current_connected: # Connection restored (might have reconnected automatically) logger.info("Radio connection restored") - broadcast_health(True, self._port) + broadcast_health(True, self._connection_info) self._last_connected = True except asyncio.CancelledError: diff --git a/app/routers/health.py b/app/routers/health.py index 1554e3c..20aadcb 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -13,12 +13,12 @@ router = APIRouter(tags=["health"]) class HealthResponse(BaseModel): status: str radio_connected: bool - serial_port: str | None + connection_info: str | None database_size_mb: float oldest_undecrypted_timestamp: int | None -async def build_health_data(radio_connected: bool, serial_port: str | None) -> dict: +async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict: """Build the health status payload used by REST endpoint and WebSocket broadcasts.""" db_size_mb = 0.0 try: @@ -36,7 +36,7 @@ async def build_health_data(radio_connected: bool, serial_port: str | None) -> d return { "status": "ok" if radio_connected else "degraded", "radio_connected": radio_connected, - "serial_port": serial_port, + "connection_info": connection_info, "database_size_mb": db_size_mb, "oldest_undecrypted_timestamp": oldest_ts, } @@ -45,5 +45,5 @@ async def build_health_data(radio_connected: bool, serial_port: str | None) -> d @router.get("/health", response_model=HealthResponse) async def healthcheck() -> HealthResponse: """Check if the API is running and if the radio is connected.""" - data = await build_health_data(radio_manager.is_connected, radio_manager.port) + data = await build_health_data(radio_manager.is_connected, radio_manager.connection_info) return HealthResponse(**data) diff --git a/app/routers/ws.py b/app/routers/ws.py index 14c4824..c9eafdb 100644 --- a/app/routers/ws.py +++ b/app/routers/ws.py @@ -23,7 +23,9 @@ async def websocket_endpoint(websocket: WebSocket) -> None: # Send initial health status try: - health_data = await build_health_data(radio_manager.is_connected, radio_manager.port) + health_data = await build_health_data( + radio_manager.is_connected, radio_manager.connection_info + ) await ws_manager.send_personal(websocket, "health", health_data) except Exception as e: diff --git a/app/websocket.py b/app/websocket.py index 12230c3..f38be5f 100644 --- a/app/websocket.py +++ b/app/websocket.py @@ -123,13 +123,13 @@ def broadcast_success(message: str, details: str | None = None) -> None: asyncio.create_task(ws_manager.broadcast("success", data)) -def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None: +def broadcast_health(radio_connected: bool, connection_info: str | None = None) -> None: """Broadcast health status change to all connected clients.""" async def _broadcast(): from app.routers.health import build_health_data - data = await build_health_data(radio_connected, serial_port) + data = await build_health_data(radio_connected, connection_info) await ws_manager.broadcast("health", data) asyncio.create_task(_broadcast()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8a4de7..58b18bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -136,7 +136,9 @@ export function App() { if (prev !== null && prev.radio_connected !== data.radio_connected) { if (data.radio_connected) { toast.success('Radio connected', { - description: data.serial_port ? `Connected to ${data.serial_port}` : undefined, + description: data.connection_info + ? `Connected via ${data.connection_info}` + : undefined, }); // Refresh config after reconnection (may have changed after reboot) api.getRadioConfig().then(setConfig).catch(console.error); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index acb2121..b1917e5 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -78,7 +78,7 @@ export function SettingsModal({ onRefreshAppSettings, }: SettingsModalProps) { // Tab state - type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'bot'; + type SettingsTab = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot'; const [activeTab, setActiveTab] = useState('radio'); // Radio config state @@ -285,7 +285,7 @@ export function SettingsModal({ } }; - const handleSaveSerial = async () => { + const handleSaveConnectivity = async () => { setError(''); setLoading(true); @@ -294,7 +294,7 @@ export function SettingsModal({ if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) { await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts }); } - toast.success('Serial settings saved'); + toast.success('Connectivity settings saved'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); } finally { @@ -474,7 +474,7 @@ export function SettingsModal({ {activeTab === 'radio' && 'Configure radio frequency, power, and location settings'} {activeTab === 'identity' && 'Manage radio name, public key, private key, and advertising settings'} - {activeTab === 'serial' && 'View serial port connection and configure contact sync'} + {activeTab === 'connectivity' && 'View connection status and configure contact sync'} {activeTab === 'database' && 'View database statistics and clean up old packets'} {activeTab === 'bot' && 'Configure automatic message bot with Python code'} @@ -494,7 +494,7 @@ export function SettingsModal({ Radio Identity - Serial + Connectivity Database Bot @@ -719,15 +719,15 @@ export function SettingsModal({ {error && {error}} - {/* Serial Tab */} - + {/* Connectivity Tab */} + - Serial Port - {health?.serial_port ? ( + Connection + {health?.connection_info ? ( - {health.serial_port} + {health.connection_info} ) : ( @@ -755,7 +755,7 @@ export function SettingsModal({ - + {loading ? 'Saving...' : 'Save Settings'} diff --git a/frontend/src/test/websocket.test.ts b/frontend/src/test/websocket.test.ts index a293c46..caf9fa9 100644 --- a/frontend/src/test/websocket.test.ts +++ b/frontend/src/test/websocket.test.ts @@ -70,7 +70,7 @@ describe('parseWebSocketMessage', () => { const onHealth = vi.fn(); const data = JSON.stringify({ type: 'health', - data: { radio_connected: true, serial_port: '/dev/ttyUSB0' }, + data: { radio_connected: true, connection_info: 'Serial: /dev/ttyUSB0' }, }); const result = parseWebSocketMessage(data, { onHealth }); @@ -79,7 +79,7 @@ describe('parseWebSocketMessage', () => { expect(result.handled).toBe(true); expect(onHealth).toHaveBeenCalledWith({ radio_connected: true, - serial_port: '/dev/ttyUSB0', + connection_info: 'Serial: /dev/ttyUSB0', }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3dbff29..59bf811 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -26,7 +26,7 @@ export interface RadioConfigUpdate { export interface HealthStatus { status: string; radio_connected: boolean; - serial_port: string | null; + connection_info: string | null; database_size_mb: number; oldest_undecrypted_timestamp: number | null; } diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 12aa4d4..c94a0fe 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -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)); diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index aaf80fa..a03c267 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -21,7 +21,7 @@ async function fetchJson(path: string, init?: RequestInit): Promise { export interface HealthStatus { radio_connected: boolean; - serial_port: string | null; + connection_info: string | null; } export function getHealth(): Promise { diff --git a/tests/test_api.py b/tests/test_api.py index 9211d81..99f0cba 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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")) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..fec13f0 --- /dev/null +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_radio.py b/tests/test_radio.py new file mode 100644 index 0000000..333c15b --- /dev/null +++ b/tests/test_radio.py @@ -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
- {health.serial_port} + {health.connection_info}