1 Commits

Author SHA1 Message Date
Jack Kingsman
b8212d4d31 Move to multi-connection modality 2026-02-04 14:46:41 -08:00
23 changed files with 427 additions and 63 deletions

View File

@@ -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.

View File

@@ -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
<details>
<summary>Finding your serial port</summary>
**Find your serial port:**
```bash
#######
# Linux
@@ -50,8 +52,8 @@ usbipd list
# attach device to WSL
usbipd bind --busid 3-8
```
</details>
## Quick Start
@@ -67,9 +69,16 @@ uv sync
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
@@ -80,12 +89,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
@@ -139,11 +159,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
<details>

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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())

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-CWnjp-zX.js"></script>
<script type="module" crossorigin src="/assets/index-BteQsTFF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
</head>
<body>

View File

@@ -135,7 +135,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);

View File

@@ -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<SettingsTab>('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'}
</DialogDescription>
@@ -491,7 +491,7 @@ export function SettingsModal({
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="radio">Radio</TabsTrigger>
<TabsTrigger value="identity">Identity</TabsTrigger>
<TabsTrigger value="serial">Serial</TabsTrigger>
<TabsTrigger value="connectivity">Connectivity</TabsTrigger>
<TabsTrigger value="database">Database</TabsTrigger>
<TabsTrigger value="bot">Bot</TabsTrigger>
</TabsList>
@@ -716,15 +716,15 @@ export function SettingsModal({
{error && <div className="text-sm text-destructive">{error}</div>}
</TabsContent>
{/* Serial Tab */}
<TabsContent value="serial" className="space-y-4 mt-4">
{/* Connectivity Tab */}
<TabsContent value="connectivity" className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Serial Port</Label>
{health?.serial_port ? (
<Label>Connection</Label>
{health?.connection_info ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.serial_port}
{health.connection_info}
</code>
</div>
) : (
@@ -752,7 +752,7 @@ export function SettingsModal({
</p>
</div>
<Button onClick={handleSaveSerial} disabled={loading} className="w-full">
<Button onClick={handleSaveConnectivity} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Settings'}
</Button>

View File

@@ -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',
});
});

View File

@@ -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;
}

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));

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

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
tests/test_config.py Normal file
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
tests/test_radio.py Normal file
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