""" rMesh Bridge — Unified REST + WebSocket API for Reticulum (Internet backbone), MeshCore (LoRa mesh), LXMF messaging, audio calls, and NomadNet browsing. """ import asyncio import logging from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Header, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse from pydantic import BaseModel from typing import Annotated, Optional from . import reticulum_bridge, lxmf_bridge, meshcore_bridge, websocket_manager, rchats_bridge from .config import BRIDGE_API_KEY, MESHCORE_ENABLED, RCHATS_BRIDGE_ENABLED from .models import ( StatusResponse, NodesResponse, TopologyResponse, MessageIn, MessageOut, MessagesResponse, IdentityResponse, AnnounceResponse, HealthResponse, ) logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s") logger = logging.getLogger("rmesh.api") @asynccontextmanager async def lifespan(app: FastAPI): """Initialize all subsystems on startup.""" logger.info("Starting rMesh bridge...") # Set up WebSocket event loop loop = asyncio.get_event_loop() websocket_manager.set_event_loop(loop) # Register WebSocket broadcast as event listener for all subsystems reticulum_bridge.register_event_listener(websocket_manager.on_event) lxmf_bridge.register_event_listener(websocket_manager.on_event) # Register rChats bridge as event listener if RCHATS_BRIDGE_ENABLED: lxmf_bridge.register_event_listener(rchats_bridge.on_lxmf_event) rchats_bridge.init() # Reticulum (Internet backbone) reticulum_bridge.init() from .reticulum_bridge import _identity if _identity: lxmf_bridge.init(_identity) else: logger.error("No Reticulum identity available for LXMF") reticulum_bridge.announce() reticulum_bridge.announce_call_capability() # MeshCore (LoRa mesh) if MESHCORE_ENABLED: await meshcore_bridge.init() else: logger.info("MeshCore disabled (set MESHCORE_ENABLED=true to activate)") yield logger.info("Shutting down rMesh bridge") if MESHCORE_ENABLED: await meshcore_bridge.disconnect() app = FastAPI(title="rMesh Bridge", lifespan=lifespan) def verify_api_key(x_bridge_api_key: Annotated[str, Header()] = ""): if not BRIDGE_API_KEY: return if x_bridge_api_key != BRIDGE_API_KEY: raise HTTPException(status_code=401, detail="Invalid API key") # ═══════════════════════════════════════════════════════════════════ # WebSocket (real-time events) # ═══════════════════════════════════════════════════════════════════ @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket_manager.connect(websocket) try: while True: data = await websocket.receive_text() # Handle ping/pong if data == "ping": await websocket.send_text('{"type":"pong"}') except WebSocketDisconnect: websocket_manager.disconnect(websocket) except Exception: websocket_manager.disconnect(websocket) # ═══════════════════════════════════════════════════════════════════ # Health & Combined Status # ═══════════════════════════════════════════════════════════════════ @app.get("/api/health", response_model=HealthResponse) async def health(): return {"status": "ok"} @app.get("/api/status") async def combined_status(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return { "reticulum": reticulum_bridge.get_status(), "meshcore": meshcore_bridge.get_status() if MESHCORE_ENABLED else { "connected": False, "device_info": {}, "contact_count": 0, "message_count": 0 }, "propagation": lxmf_bridge.get_propagation_status(), "websocket_clients": websocket_manager.client_count(), } # ═══════════════════════════════════════════════════════════════════ # Reticulum Endpoints # ═══════════════════════════════════════════════════════════════════ @app.get("/api/reticulum/status", response_model=StatusResponse) async def reticulum_status(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return reticulum_bridge.get_status() @app.get("/api/reticulum/nodes") async def reticulum_nodes(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) node_list = reticulum_bridge.get_nodes() return {"nodes": node_list, "total": len(node_list)} @app.get("/api/reticulum/topology") async def reticulum_topology(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return reticulum_bridge.get_topology() @app.post("/api/reticulum/announce", response_model=AnnounceResponse) async def reticulum_announce(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return reticulum_bridge.announce() @app.get("/api/reticulum/identity", response_model=IdentityResponse) async def reticulum_identity(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return reticulum_bridge.get_identity_info() # ═══════════════════════════════════════════════════════════════════ # LXMF Messages (with rich fields) # ═══════════════════════════════════════════════════════════════════ @app.get("/api/reticulum/messages") async def reticulum_messages( x_bridge_api_key: Annotated[str, Header()] = "", limit: int = 100, offset: int = 0, ): verify_api_key(x_bridge_api_key) msgs = lxmf_bridge.get_messages(limit=limit, offset=offset) return {"messages": msgs, "total": lxmf_bridge.get_total_count()} @app.post("/api/reticulum/messages") async def reticulum_send_message( body: MessageIn, x_bridge_api_key: Annotated[str, Header()] = "", ): verify_api_key(x_bridge_api_key) result = lxmf_bridge.send_message( destination_hash_hex=body.destination_hash, content=body.content, title=body.title, ) if not result.get("id"): raise HTTPException(status_code=400, detail="Failed to send message") return result @app.get("/api/reticulum/messages/{msg_id}") async def reticulum_get_message(msg_id: str, x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) msg = lxmf_bridge.get_message(msg_id) if msg is None: raise HTTPException(status_code=404, detail="Message not found") return msg @app.get("/api/attachments/{filename}") async def get_attachment(filename: str, x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) # Sanitize filename safe = "".join(c for c in filename if c.isalnum() or c in ".-_") path = lxmf_bridge.get_attachment_path(safe) if path is None: raise HTTPException(status_code=404, detail="Attachment not found") return FileResponse(path) # ═══════════════════════════════════════════════════════════════════ # Propagation Node Management # ═══════════════════════════════════════════════════════════════════ @app.get("/api/propagation/status") async def propagation_status(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return lxmf_bridge.get_propagation_status() @app.get("/api/propagation/nodes") async def propagation_nodes(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) nodes = lxmf_bridge.get_propagation_nodes() return {"nodes": nodes, "total": len(nodes)} class PropagationNodeSet(BaseModel): destination_hash: str @app.post("/api/propagation/preferred") async def set_preferred_propagation(body: PropagationNodeSet, x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) ok = lxmf_bridge.set_preferred_propagation_node(body.destination_hash) if not ok: raise HTTPException(status_code=400, detail="Failed to set propagation node") return {"status": "ok", "destination_hash": body.destination_hash} @app.post("/api/propagation/sync") async def sync_propagation(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) lxmf_bridge.sync_propagation_messages() return {"status": "syncing"} # ═══════════════════════════════════════════════════════════════════ # Audio Calls # ═══════════════════════════════════════════════════════════════════ class CallInitiate(BaseModel): destination_hash: str @app.get("/api/calls") async def list_calls(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return {"calls": reticulum_bridge.get_active_calls()} @app.post("/api/calls") async def initiate_call(body: CallInitiate, x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) result = reticulum_bridge.initiate_call(body.destination_hash) if not result.get("call_id"): raise HTTPException(status_code=400, detail=result.get("error", "Call failed")) return result class CallHangup(BaseModel): call_id: str @app.post("/api/calls/hangup") async def hangup_call(body: CallHangup, x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) reticulum_bridge.hangup_call(body.call_id) return {"status": "ok"} # ═══════════════════════════════════════════════════════════════════ # NomadNet Node Browser # ═══════════════════════════════════════════════════════════════════ @app.get("/api/nomadnet/nodes") async def nomadnet_nodes(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) nodes = reticulum_bridge.get_nomadnet_nodes() return {"nodes": nodes, "total": len(nodes)} class NomadNetBrowse(BaseModel): destination_hash: str path: str = "/" @app.post("/api/nomadnet/browse") async def nomadnet_browse(body: NomadNetBrowse, x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) result = reticulum_bridge.browse_nomadnet_node(body.destination_hash, body.path) if "error" in result: raise HTTPException(status_code=400, detail=result["error"]) return result # ═══════════════════════════════════════════════════════════════════ # MeshCore Endpoints (LoRa mesh) # ═══════════════════════════════════════════════════════════════════ class MCMessageIn(BaseModel): contact_name: str content: str class MCChannelMessageIn(BaseModel): channel_idx: int content: str @app.get("/api/meshcore/status") async def meshcore_status(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return meshcore_bridge.get_status() @app.get("/api/meshcore/contacts") async def meshcore_contacts(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) contacts = meshcore_bridge.get_contacts() return {"contacts": contacts, "total": len(contacts)} @app.post("/api/meshcore/contacts/refresh") async def meshcore_refresh_contacts(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) contacts = await meshcore_bridge.refresh_contacts() return {"contacts": contacts, "total": len(contacts)} @app.get("/api/meshcore/messages") async def meshcore_messages( x_bridge_api_key: Annotated[str, Header()] = "", limit: int = 100, offset: int = 0, ): verify_api_key(x_bridge_api_key) msgs = meshcore_bridge.get_messages(limit=limit, offset=offset) return {"messages": msgs, "total": meshcore_bridge.get_message_count()} @app.post("/api/meshcore/messages") async def meshcore_send_message( body: MCMessageIn, x_bridge_api_key: Annotated[str, Header()] = "", ): verify_api_key(x_bridge_api_key) result = await meshcore_bridge.send_message(body.contact_name, body.content) if not result.get("id"): raise HTTPException(status_code=400, detail=result.get("error", "Failed to send")) return result @app.post("/api/meshcore/channels/messages") async def meshcore_send_channel_message( body: MCChannelMessageIn, x_bridge_api_key: Annotated[str, Header()] = "", ): verify_api_key(x_bridge_api_key) result = await meshcore_bridge.send_channel_message(body.channel_idx, body.content) if not result.get("id"): raise HTTPException(status_code=400, detail=result.get("error", "Failed to send")) return result @app.post("/api/meshcore/advert") async def meshcore_advert(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return await meshcore_bridge.send_advert() @app.get("/api/meshcore/stats") async def meshcore_stats(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return await meshcore_bridge.get_device_stats() # ═══════════════════════════════════════════════════════════════════ # Backwards Compatibility Aliases # ═══════════════════════════════════════════════════════════════════ @app.get("/api/nodes") async def nodes_compat(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) node_list = reticulum_bridge.get_nodes() return {"nodes": node_list, "total": len(node_list)} @app.get("/api/topology") async def topology_compat(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return reticulum_bridge.get_topology() @app.get("/api/identity") async def identity_compat(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return reticulum_bridge.get_identity_info()