mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-05 17:32:10 +02:00
Initial loopbacl
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user