Initial loopbacl

This commit is contained in:
Jack Kingsman
2026-03-02 07:16:58 -08:00
parent ed83d1b2c4
commit d00bc68a83
17 changed files with 1429 additions and 1 deletions
+5
View File
@@ -35,6 +35,11 @@ class Settings(BaseSettings):
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
return self
@property
def loopback_eligible(self) -> bool:
"""True when no explicit transport env var is set."""
return not self.serial_port and not self.tcp_host and not self.ble_address
@property
def connection_type(self) -> Literal["serial", "tcp", "ble"]:
if self.tcp_host:
+125
View File
@@ -0,0 +1,125 @@
"""Loopback transport: bridges a browser-side serial/BLE connection over WebSocket."""
import asyncio
import logging
from typing import Any, Literal
from starlette.websockets import WebSocket, WebSocketState
logger = logging.getLogger(__name__)
class LoopbackTransport:
"""ConnectionProtocol implementation that tunnels bytes over a WebSocket.
For serial mode, applies the same 0x3c + 2-byte LE size framing that
meshcore's SerialConnection uses. For BLE mode, passes raw bytes through
(matching BLEConnection behaviour).
"""
def __init__(self, websocket: WebSocket, mode: Literal["serial", "ble"]) -> None:
self._ws = websocket
self._mode = mode
self._reader: Any = None
self._disconnect_callback: Any = None
# Serial framing state (mirrors meshcore serial_cx.py handle_rx)
self._header = b""
self._inframe = b""
self._frame_started = False
self._frame_size = 0
# -- ConnectionProtocol methods ------------------------------------------
async def connect(self) -> str:
"""No-op — the WebSocket is already established."""
info = f"Loopback ({self._mode})"
logger.info("Loopback transport connected: %s", info)
return info
async def disconnect(self) -> None:
"""Ask the browser to release the hardware and close the WS."""
try:
if self._ws.client_state == WebSocketState.CONNECTED:
await self._ws.send_json({"type": "disconnect"})
except Exception:
pass # WS may already be closed
async def send(self, data: Any) -> None:
"""Send data to the browser (which writes it to the physical radio).
Serial mode: prepend 0x3c + 2-byte LE size header.
BLE mode: send raw bytes.
"""
try:
if self._ws.client_state != WebSocketState.CONNECTED:
return
if self._mode == "serial":
size = len(data)
pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + bytes(data)
await self._ws.send_bytes(pkt)
else:
await self._ws.send_bytes(bytes(data))
except Exception as e:
logger.debug("Loopback send error: %s", e)
def set_reader(self, reader: Any) -> None:
self._reader = reader
def set_disconnect_callback(self, callback: Any) -> None:
self._disconnect_callback = callback
# -- Incoming data from browser ------------------------------------------
def handle_rx(self, data: bytes) -> None:
"""Process bytes received from the browser.
Serial mode: accumulate bytes, strip framing, deliver payload.
BLE mode: deliver raw bytes directly.
"""
if self._mode == "serial":
self._handle_rx_serial(data)
else:
self._handle_rx_ble(data)
def _handle_rx_ble(self, data: bytes) -> None:
if self._reader is not None:
asyncio.create_task(self._reader.handle_rx(data))
def _handle_rx_serial(self, data: bytes) -> None:
"""Mirror meshcore's SerialConnection.handle_rx state machine."""
raw = bytes(data)
headerlen = len(self._header)
if not self._frame_started:
if len(raw) >= 3 - headerlen:
self._header = self._header + raw[: 3 - headerlen]
self._frame_started = True
self._frame_size = int.from_bytes(self._header[1:], byteorder="little")
remainder = raw[3 - headerlen :]
# Reset header for next frame
self._header = b""
if remainder:
self._handle_rx_serial(remainder)
else:
self._header = self._header + raw
else:
framelen = len(self._inframe)
if framelen + len(raw) < self._frame_size:
self._inframe = self._inframe + raw
else:
self._inframe = self._inframe + raw[: self._frame_size - framelen]
if self._reader is not None:
asyncio.create_task(self._reader.handle_rx(self._inframe))
remainder = raw[self._frame_size - framelen :]
self._frame_started = False
self._inframe = b""
if remainder:
self._handle_rx_serial(remainder)
def reset_framing(self) -> None:
"""Reset the serial framing state machine."""
self._header = b""
self._inframe = b""
self._frame_started = False
self._frame_size = 0
+2
View File
@@ -19,6 +19,7 @@ from app.routers import (
channels,
contacts,
health,
loopback,
messages,
packets,
radio,
@@ -126,6 +127,7 @@ app.include_router(read_state.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(statistics.router, prefix="/api")
app.include_router(ws.router, prefix="/api")
app.include_router(loopback.router, prefix="/api")
# Serve frontend static files in production
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
+35
View File
@@ -128,6 +128,7 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self._loopback_active: bool = False
async def _acquire_operation_lock(
self,
@@ -317,6 +318,36 @@ class RadioManager:
def is_setup_complete(self) -> bool:
return self._setup_complete
@property
def loopback_active(self) -> bool:
return self._loopback_active
def connect_loopback(self, mc: MeshCore, connection_info: str) -> None:
"""Adopt a MeshCore instance created by the loopback WebSocket endpoint."""
self._meshcore = mc
self._connection_info = connection_info
self._loopback_active = True
self._last_connected = True
self._setup_complete = False
async def disconnect_loopback(self) -> None:
"""Tear down a loopback session and resume normal auto-detect."""
from app.websocket import broadcast_health
mc = self._meshcore
self._meshcore = None
self._loopback_active = False
self._connection_info = None
self._setup_complete = False
if mc is not None:
try:
await mc.disconnect()
except Exception:
pass
broadcast_health(False, None)
async def connect(self) -> None:
"""Connect to the radio using the configured transport."""
if self._meshcore is not None:
@@ -461,6 +492,10 @@ class RadioManager:
try:
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
# Skip auto-detect/reconnect while loopback is active
if self._loopback_active:
continue
current_connected = self.is_connected
# Detect status change
+2
View File
@@ -17,6 +17,7 @@ class HealthResponse(BaseModel):
database_size_mb: float
oldest_undecrypted_timestamp: int | None
mqtt_status: str | None = None
loopback_eligible: bool = False
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
@@ -53,6 +54,7 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"mqtt_status": mqtt_status,
"loopback_eligible": settings.loopback_eligible,
}
+116
View File
@@ -0,0 +1,116 @@
"""WebSocket endpoint for loopback transport (browser-bridged radio connection)."""
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from starlette.websockets import WebSocketState
from app.config import settings
from app.loopback import LoopbackTransport
from app.radio import radio_manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.websocket("/ws/transport")
async def loopback_transport(websocket: WebSocket) -> None:
"""Bridge a browser-side serial/BLE connection to the backend MeshCore stack.
Protocol:
1. Client sends init JSON: {"type": "init", "mode": "serial"|"ble"}
2. Binary frames flow bidirectionally (raw bytes for BLE, framed for serial)
3. Either side can send {"type": "disconnect"} to tear down
"""
# Guard: reject if an explicit transport is configured via env vars
if not settings.loopback_eligible:
await websocket.accept()
await websocket.close(code=4003, reason="Explicit transport configured")
return
# Guard: reject if the radio is already connected (direct or another loopback)
if radio_manager.is_connected:
await websocket.accept()
await websocket.close(code=4004, reason="Radio already connected")
return
await websocket.accept()
transport: LoopbackTransport | None = None
setup_task: asyncio.Task | None = None
try:
# Wait for init message
init_raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
init_msg = json.loads(init_raw)
if init_msg.get("type") != "init" or init_msg.get("mode") not in ("serial", "ble"):
await websocket.close(code=4001, reason="Invalid init message")
return
mode = init_msg["mode"]
logger.info("Loopback init: mode=%s", mode)
# Create transport and MeshCore instance
transport = LoopbackTransport(websocket, mode)
from meshcore import MeshCore
mc = MeshCore(transport, auto_reconnect=False, max_reconnect_attempts=0)
await mc.connect()
if not mc.is_connected:
logger.warning("Loopback MeshCore failed to connect")
await websocket.close(code=4005, reason="MeshCore handshake failed")
return
connection_info = f"Loopback ({mode})"
radio_manager.connect_loopback(mc, connection_info)
# Run post-connect setup in background so the receive loop can run
setup_task = asyncio.create_task(radio_manager.post_connect_setup())
# Main receive loop
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
if "bytes" in message and message["bytes"]:
transport.handle_rx(message["bytes"])
elif "text" in message and message["text"]:
try:
text_msg = json.loads(message["text"])
if text_msg.get("type") == "disconnect":
logger.info("Loopback client requested disconnect")
break
except (json.JSONDecodeError, TypeError):
pass
except WebSocketDisconnect:
logger.info("Loopback WebSocket disconnected")
except asyncio.TimeoutError:
logger.warning("Loopback init timeout")
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.close(code=4002, reason="Init timeout")
except Exception as e:
logger.exception("Loopback error: %s", e)
finally:
if setup_task is not None:
setup_task.cancel()
try:
await setup_task
except (asyncio.CancelledError, Exception):
pass
await radio_manager.disconnect_loopback()
if websocket.client_state == WebSocketState.CONNECTED:
try:
await websocket.close()
except Exception:
pass