Move to multi-connection modality

This commit is contained in:
Jack Kingsman
2026-02-04 14:22:49 -08:00
committed by jkingsman
parent 64d261e6f9
commit a86d2d7cda
23 changed files with 427 additions and 63 deletions
+7 -4
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)
+31
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()
+51 -9
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:
+4 -4
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)
+3 -1
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:
+2 -2
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())