mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Merge pull request #14 from jkingsman/support-tcp-and-ble-connections-on-backend
Move to multi-connection modality
This commit is contained in:
10
AGENTS.md
10
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.
|
||||
|
||||
38
README.md
38
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
|
||||
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
|
||||
<details>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
60
app/radio.py
60
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -494,7 +494,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>
|
||||
@@ -719,15 +719,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>
|
||||
) : (
|
||||
@@ -755,7 +755,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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
82
tests/test_config.py
Normal 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
170
tests/test_radio.py
Normal 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
|
||||
Reference in New Issue
Block a user