forked from iarv/Remote-Terminal-for-MeshCore
Compare commits
1 Commits
single-nod
...
support-tc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8212d4d31 |
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
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
1
frontend/dist/assets/index-BteQsTFF.js.map
vendored
Normal file
1
frontend/dist/assets/index-BteQsTFF.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-CWnjp-zX.js.map
vendored
1
frontend/dist/assets/index-CWnjp-zX.js.map
vendored
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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