""" rMesh Bridge — FastAPI service exposing Reticulum (Internet backbone) and MeshCore (LoRa mesh) as a unified REST API. """ import logging from contextlib import asynccontextmanager from typing import Optional from fastapi import FastAPI, HTTPException, Header from pydantic import BaseModel from typing import Annotated from . import reticulum_bridge, lxmf_bridge, meshcore_bridge from .config import BRIDGE_API_KEY, MESHCORE_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 Reticulum and MeshCore on startup.""" logger.info("Starting rMesh bridge...") # 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() # 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") # ═══════════════════════════════════════════════════════════════════ # 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) result = { "reticulum": reticulum_bridge.get_status(), "meshcore": meshcore_bridge.get_status() if MESHCORE_ENABLED else {"connected": False, "device_info": {}, "contact_count": 0, "message_count": 0}, } return result # ═══════════════════════════════════════════════════════════════════ # Reticulum Endpoints (Internet backbone) # ═══════════════════════════════════════════════════════════════════ @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", response_model=NodesResponse) 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", response_model=TopologyResponse) 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() @app.get("/api/reticulum/messages", response_model=MessagesResponse) 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", response_model=MessageOut) 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 # Keep old /api/status path as alias for backwards compat @app.get("/api/nodes", response_model=NodesResponse) 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", response_model=TopologyResponse) 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", response_model=IdentityResponse) async def identity_compat(x_bridge_api_key: Annotated[str, Header()] = ""): verify_api_key(x_bridge_api_key) return reticulum_bridge.get_identity_info() # ═══════════════════════════════════════════════════════════════════ # 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()