commit f04dcbfa7900f020692cb41fa79bebc188255752 Author: Jeff Emmett Date: Mon Apr 6 16:42:10 2026 +0000 Initial commit: rMesh Reticulum + MeshCore backend Dual-stack mesh networking backend for rSpace: - Reticulum (rnsd + lxmd) for encrypted Internet backbone - MeshCore (meshcore_py) for LoRa mesh via companion protocol - FastAPI bridge with unified REST API on port 8000 - Supervisord manages rnsd, lxmd, and uvicorn processes - Hardware BOM and gateway setup documentation Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b9089d --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +BRIDGE_API_KEY=change-me-to-a-secure-random-string diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcf20e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +*.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e35d3de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system deps for cryptography +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt supervisor + +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY config/ /app/config/ +COPY app/ /app/app/ +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Data volumes for persistent Reticulum state and LXMF storage +VOLUME ["/data/reticulum", "/data/lxmf"] + +EXPOSE 4242 8000 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..03f4a5e --- /dev/null +++ b/app/config.py @@ -0,0 +1,15 @@ +import os + +BRIDGE_API_KEY = os.environ.get("BRIDGE_API_KEY", "") +RNS_CONFIG_DIR = os.environ.get("RNS_CONFIG_DIR", "/data/reticulum") +LXMF_STORAGE_DIR = os.environ.get("LXMF_STORAGE_DIR", "/data/lxmf") +LXMF_MESSAGES_FILE = os.path.join(LXMF_STORAGE_DIR, "bridge_messages.json") +LOG_LEVEL = int(os.environ.get("RNS_LOG_LEVEL", "4")) + +# MeshCore companion node connection +MESHCORE_ENABLED = os.environ.get("MESHCORE_ENABLED", "false").lower() == "true" +MESHCORE_HOST = os.environ.get("MESHCORE_HOST", "") # TCP host (WiFi companion) +MESHCORE_PORT = int(os.environ.get("MESHCORE_PORT", "5000")) +MESHCORE_SERIAL = os.environ.get("MESHCORE_SERIAL", "") # Serial port (USB companion) +MESHCORE_DATA_DIR = os.environ.get("MESHCORE_DATA_DIR", "/data/meshcore") +MESHCORE_MESSAGES_FILE = os.path.join(MESHCORE_DATA_DIR, "messages.json") diff --git a/app/lxmf_bridge.py b/app/lxmf_bridge.py new file mode 100644 index 0000000..ac916c5 --- /dev/null +++ b/app/lxmf_bridge.py @@ -0,0 +1,204 @@ +""" +LXMF bridge — message send/receive via the LXMF protocol on Reticulum. +""" + +import json +import time +import uuid +import threading +import logging +import os + +import RNS +import LXMF + +from .config import LXMF_STORAGE_DIR, LXMF_MESSAGES_FILE + +logger = logging.getLogger("rmesh.lxmf") + +_router: LXMF.LXMRouter | None = None +_local_delivery_destination: RNS.Destination | None = None +_identity: RNS.Identity | None = None +_messages: list[dict] = [] +_lock = threading.Lock() + + +def init(identity: RNS.Identity): + """Initialize the LXMF router with the given identity.""" + global _router, _local_delivery_destination, _identity + + _identity = identity + + os.makedirs(LXMF_STORAGE_DIR, exist_ok=True) + + _router = LXMF.LXMRouter( + identity=_identity, + storagepath=LXMF_STORAGE_DIR, + ) + + # Enable propagation node + _router.enable_propagation() + + # Register local delivery destination for receiving messages + _local_delivery_destination = _router.register_delivery_identity( + _identity, + display_name="rMesh Bridge", + ) + + _router.register_delivery_callback(_delivery_callback) + + # Load persisted messages + _load_messages() + + logger.info("LXMF bridge ready — delivery hash: %s", _identity.hexhash) + + +def _delivery_callback(message): + """Handle incoming LXMF message.""" + msg_record = { + "id": str(uuid.uuid4()), + "direction": "inbound", + "sender_hash": message.source_hash.hex() if message.source_hash else "", + "recipient_hash": _identity.hexhash if _identity else "", + "title": message.title.decode("utf-8") if isinstance(message.title, bytes) else str(message.title or ""), + "content": message.content.decode("utf-8") if isinstance(message.content, bytes) else str(message.content or ""), + "status": "delivered", + "timestamp": time.time(), + } + + with _lock: + _messages.append(msg_record) + _save_messages() + + logger.info("Received LXMF message from %s", msg_record["sender_hash"][:16]) + + +def send_message(destination_hash_hex: str, content: str, title: str = "") -> dict: + """Send an LXMF message to a destination hash.""" + if _router is None or _identity is None: + return {"id": "", "status": "failed"} + + try: + dest_hash = bytes.fromhex(destination_hash_hex) + except ValueError: + return {"id": "", "status": "failed"} + + # Look up identity for destination + dest_identity = RNS.Identity.recall(dest_hash) + + msg_id = str(uuid.uuid4()) + + if dest_identity is None: + # Request path first, the message may be deliverable later + RNS.Transport.request_path(dest_hash) + msg_record = { + "id": msg_id, + "direction": "outbound", + "sender_hash": _identity.hexhash, + "recipient_hash": destination_hash_hex, + "title": title, + "content": content, + "status": "pending", + "timestamp": time.time(), + } + with _lock: + _messages.append(msg_record) + _save_messages() + return msg_record + + # Create destination for the recipient + dest = RNS.Destination( + dest_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, + "lxmf", "delivery", + ) + + lxm = LXMF.LXMessage( + dest, + _router.get_delivery_destination(), + content.encode("utf-8") if isinstance(content, str) else content, + title=title.encode("utf-8") if isinstance(title, str) else title, + desired_method=LXMF.LXMessage.DIRECT, + ) + + msg_record = { + "id": msg_id, + "direction": "outbound", + "sender_hash": _identity.hexhash, + "recipient_hash": destination_hash_hex, + "title": title, + "content": content, + "status": "pending", + "timestamp": time.time(), + } + + def _delivery_callback_outbound(message): + with _lock: + for m in _messages: + if m["id"] == msg_id: + m["status"] = "delivered" + break + _save_messages() + + def _failed_callback(message): + with _lock: + for m in _messages: + if m["id"] == msg_id: + m["status"] = "failed" + break + _save_messages() + + lxm.delivery_callback = _delivery_callback_outbound + lxm.failed_callback = _failed_callback + + _router.handle_outbound(lxm) + + with _lock: + _messages.append(msg_record) + _save_messages() + + return msg_record + + +def get_messages(limit: int = 100, offset: int = 0) -> list[dict]: + """Return stored messages.""" + with _lock: + sorted_msgs = sorted(_messages, key=lambda m: m["timestamp"], reverse=True) + return sorted_msgs[offset:offset + limit] + + +def get_message(msg_id: str) -> dict | None: + """Return a single message by ID.""" + with _lock: + for m in _messages: + if m["id"] == msg_id: + return m + return None + + +def get_total_count() -> int: + """Return total message count.""" + with _lock: + return len(_messages) + + +def _load_messages(): + """Load messages from persistent storage.""" + global _messages + if os.path.exists(LXMF_MESSAGES_FILE): + try: + with open(LXMF_MESSAGES_FILE, "r") as f: + _messages = json.load(f) + logger.info("Loaded %d persisted messages", len(_messages)) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Could not load messages: %s", e) + _messages = [] + + +def _save_messages(): + """Persist messages to storage (call with _lock held).""" + try: + os.makedirs(os.path.dirname(LXMF_MESSAGES_FILE), exist_ok=True) + with open(LXMF_MESSAGES_FILE, "w") as f: + json.dump(_messages, f) + except OSError as e: + logger.warning("Could not save messages: %s", e) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..92aa6b5 --- /dev/null +++ b/app/main.py @@ -0,0 +1,240 @@ +""" +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() diff --git a/app/meshcore_bridge.py b/app/meshcore_bridge.py new file mode 100644 index 0000000..5bc95e6 --- /dev/null +++ b/app/meshcore_bridge.py @@ -0,0 +1,348 @@ +""" +MeshCore bridge — connects to a MeshCore Companion node via TCP or serial, +handles contacts, messaging, and device status. +""" + +import asyncio +import json +import time +import uuid +import os +import logging +from typing import Optional + +from .config import ( + MESHCORE_HOST, MESHCORE_PORT, MESHCORE_SERIAL, + MESHCORE_DATA_DIR, MESHCORE_MESSAGES_FILE, +) + +logger = logging.getLogger("rmesh.meshcore") + +_mc = None # MeshCore instance +_connected = False +_device_info: dict = {} +_contacts: list[dict] = [] +_messages: list[dict] = [] +_event_loop: Optional[asyncio.AbstractEventLoop] = None +_listener_task: Optional[asyncio.Task] = None + + +async def init(): + """Connect to MeshCore companion node.""" + global _mc, _connected, _event_loop + import meshcore as mc_lib + + os.makedirs(MESHCORE_DATA_DIR, exist_ok=True) + _load_messages() + + _event_loop = asyncio.get_event_loop() + + try: + if MESHCORE_SERIAL: + logger.info("Connecting to MeshCore companion via serial: %s", MESHCORE_SERIAL) + _mc = await mc_lib.MeshCore.create_serial( + port=MESHCORE_SERIAL, + auto_reconnect=True, + max_reconnect_attempts=10, + ) + elif MESHCORE_HOST: + logger.info("Connecting to MeshCore companion via TCP: %s:%d", MESHCORE_HOST, MESHCORE_PORT) + _mc = await mc_lib.MeshCore.create_tcp( + host=MESHCORE_HOST, + port=MESHCORE_PORT, + auto_reconnect=True, + max_reconnect_attempts=10, + ) + else: + logger.warning("No MeshCore connection configured (set MESHCORE_HOST or MESHCORE_SERIAL)") + return + + await _mc.connect() + _connected = True + + # Subscribe to events + _mc.subscribe(mc_lib.EventType.CONTACT_MSG_RECV, _on_contact_message) + _mc.subscribe(mc_lib.EventType.CHANNEL_MSG_RECV, _on_channel_message) + _mc.subscribe(mc_lib.EventType.NEW_CONTACT, _on_new_contact) + _mc.subscribe(mc_lib.EventType.CONNECTED, _on_connected) + _mc.subscribe(mc_lib.EventType.DISCONNECTED, _on_disconnected) + + # Fetch initial state + await _refresh_device_info() + await _refresh_contacts() + + logger.info("MeshCore bridge ready — device: %s", _device_info.get("name", "unknown")) + + except Exception as e: + logger.error("Failed to connect to MeshCore companion: %s", e) + _connected = False + + +async def _refresh_device_info(): + """Fetch device info from companion.""" + global _device_info + if not _mc: + return + try: + info = await _mc.commands.device.send_device_query() + if info: + _device_info = { + "name": getattr(info, "name", ""), + "firmware": getattr(info, "firmware", ""), + "freq": getattr(info, "freq", 0), + "bw": getattr(info, "bw", 0), + "sf": getattr(info, "sf", 0), + "cr": getattr(info, "cr", 0), + "tx_power": getattr(info, "tx_power", 0), + } + except Exception as e: + logger.warning("Failed to query device info: %s", e) + + +async def _refresh_contacts(): + """Fetch contacts from companion.""" + global _contacts + if not _mc: + return + try: + await _mc.get_contacts_async() + # meshcore_py stores contacts internally + contacts_raw = _mc._contacts if hasattr(_mc, '_contacts') else {} + _contacts = [] + for key, contact in contacts_raw.items(): + _contacts.append({ + "key_prefix": key[:16] if isinstance(key, str) else "", + "name": getattr(contact, "name", "unknown"), + "type": getattr(contact, "type", 0), + "last_seen": getattr(contact, "last_seen", None), + "path_known": getattr(contact, "path_known", False), + "public_key": key if isinstance(key, str) else "", + }) + except Exception as e: + logger.warning("Failed to fetch contacts: %s", e) + + +def _on_contact_message(event): + """Handle incoming direct message.""" + msg = { + "id": str(uuid.uuid4()), + "type": "direct", + "direction": "inbound", + "sender": getattr(event, "sender_name", "") or getattr(event, "sender", ""), + "sender_key": getattr(event, "sender_key", ""), + "content": getattr(event, "text", "") or getattr(event, "content", ""), + "channel": None, + "timestamp": time.time(), + "status": "delivered", + } + _messages.append(msg) + _save_messages() + logger.info("MeshCore DM from %s: %s", msg["sender"], msg["content"][:50]) + + +def _on_channel_message(event): + """Handle incoming channel message.""" + msg = { + "id": str(uuid.uuid4()), + "type": "channel", + "direction": "inbound", + "sender": getattr(event, "sender_name", "") or getattr(event, "sender", ""), + "sender_key": getattr(event, "sender_key", ""), + "content": getattr(event, "text", "") or getattr(event, "content", ""), + "channel": getattr(event, "channel_name", "") or getattr(event, "channel", ""), + "timestamp": time.time(), + "status": "delivered", + } + _messages.append(msg) + _save_messages() + logger.info("MeshCore channel msg [%s] from %s", msg["channel"], msg["sender"]) + + +def _on_new_contact(event): + """Handle new contact discovery.""" + logger.info("MeshCore new contact: %s", getattr(event, "name", "unknown")) + # Refresh contacts in background + if _event_loop: + asyncio.run_coroutine_threadsafe(_refresh_contacts(), _event_loop) + + +def _on_connected(event): + global _connected + _connected = True + logger.info("MeshCore companion connected") + + +def _on_disconnected(event): + global _connected + _connected = False + logger.warning("MeshCore companion disconnected") + + +# --- Public API --- + +def get_status() -> dict: + """Return MeshCore connection status.""" + return { + "connected": _connected, + "device_info": _device_info, + "contact_count": len(_contacts), + "message_count": len(_messages), + } + + +def get_contacts() -> list[dict]: + """Return known contacts.""" + return list(_contacts) + + +async def refresh_contacts() -> list[dict]: + """Force refresh contacts from companion.""" + await _refresh_contacts() + return list(_contacts) + + +async def send_message(contact_name: str, content: str) -> dict: + """Send a direct message to a contact by name.""" + if not _mc or not _connected: + return {"id": "", "status": "failed", "error": "Not connected"} + + try: + contact = _mc.get_contact_by_name(contact_name) + if not contact: + return {"id": "", "status": "failed", "error": f"Contact '{contact_name}' not found"} + + await _mc.commands.messaging.send_msg(contact, content) + + msg = { + "id": str(uuid.uuid4()), + "type": "direct", + "direction": "outbound", + "sender": _device_info.get("name", "self"), + "sender_key": "", + "content": content, + "channel": None, + "recipient": contact_name, + "timestamp": time.time(), + "status": "sent", + } + _messages.append(msg) + _save_messages() + return msg + + except Exception as e: + logger.error("Failed to send message: %s", e) + return {"id": "", "status": "failed", "error": str(e)} + + +async def send_channel_message(channel_idx: int, content: str) -> dict: + """Send a message to a channel by index.""" + if not _mc or not _connected: + return {"id": "", "status": "failed", "error": "Not connected"} + + try: + await _mc.commands.messaging.send_chan_msg(channel_idx, content) + + msg = { + "id": str(uuid.uuid4()), + "type": "channel", + "direction": "outbound", + "sender": _device_info.get("name", "self"), + "sender_key": "", + "content": content, + "channel": str(channel_idx), + "timestamp": time.time(), + "status": "sent", + } + _messages.append(msg) + _save_messages() + return msg + + except Exception as e: + logger.error("Failed to send channel message: %s", e) + return {"id": "", "status": "failed", "error": str(e)} + + +async def send_advert() -> dict: + """Send an advertisement to the mesh.""" + if not _mc or not _connected: + return {"advertised": False, "error": "Not connected"} + + try: + await _mc.commands.device.send_advert() + return {"advertised": True, "name": _device_info.get("name", "")} + except Exception as e: + return {"advertised": False, "error": str(e)} + + +async def get_device_stats() -> dict: + """Get radio and core stats from companion.""" + if not _mc or not _connected: + return {} + + stats = {} + try: + radio = await _mc.commands.device.get_stats_radio() + if radio: + stats["radio"] = {k: v for k, v in vars(radio).items() if not k.startswith("_")} if hasattr(radio, "__dict__") else str(radio) + except Exception: + pass + + try: + core = await _mc.commands.device.get_stats_core() + if core: + stats["core"] = {k: v for k, v in vars(core).items() if not k.startswith("_")} if hasattr(core, "__dict__") else str(core) + except Exception: + pass + + try: + bat = await _mc.commands.device.get_bat() + if bat is not None: + stats["battery"] = bat + except Exception: + pass + + return stats + + +def get_messages(limit: int = 100, offset: int = 0) -> list[dict]: + """Return stored messages.""" + sorted_msgs = sorted(_messages, key=lambda m: m["timestamp"], reverse=True) + return sorted_msgs[offset:offset + limit] + + +def get_message_count() -> int: + return len(_messages) + + +async def disconnect(): + """Disconnect from companion.""" + global _connected + if _mc: + try: + await _mc.disconnect() + except Exception: + pass + _connected = False + + +# --- Persistence --- + +def _load_messages(): + global _messages + if os.path.exists(MESHCORE_MESSAGES_FILE): + try: + with open(MESHCORE_MESSAGES_FILE, "r") as f: + _messages = json.load(f) + logger.info("Loaded %d MeshCore messages", len(_messages)) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Could not load MeshCore messages: %s", e) + _messages = [] + + +def _save_messages(): + try: + os.makedirs(os.path.dirname(MESHCORE_MESSAGES_FILE), exist_ok=True) + with open(MESHCORE_MESSAGES_FILE, "w") as f: + json.dump(_messages, f) + except OSError as e: + logger.warning("Could not save MeshCore messages: %s", e) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..a0259ff --- /dev/null +++ b/app/models.py @@ -0,0 +1,74 @@ +from pydantic import BaseModel +from typing import Optional + + +class StatusResponse(BaseModel): + online: bool + transport_enabled: bool + identity_hash: str + uptime_seconds: float + announced_count: int + path_count: int + + +class NodeInfo(BaseModel): + destination_hash: str + app_name: Optional[str] = None + aspects: Optional[str] = None + last_heard: Optional[float] = None + hops: Optional[int] = None + + +class NodesResponse(BaseModel): + nodes: list[NodeInfo] + total: int + + +class TopologyLink(BaseModel): + source: str + target: str + hops: int + active: bool + + +class TopologyResponse(BaseModel): + nodes: list[NodeInfo] + links: list[TopologyLink] + node_count: int + link_count: int + + +class MessageIn(BaseModel): + destination_hash: str + content: str + title: str = "" + + +class MessageOut(BaseModel): + id: str + direction: str # inbound | outbound + sender_hash: str + recipient_hash: str + title: str + content: str + status: str # pending | delivered | failed + timestamp: float + + +class MessagesResponse(BaseModel): + messages: list[MessageOut] + total: int + + +class IdentityResponse(BaseModel): + identity_hash: str + public_key_hex: str + + +class AnnounceResponse(BaseModel): + announced: bool + identity_hash: str + + +class HealthResponse(BaseModel): + status: str = "ok" diff --git a/app/reticulum_bridge.py b/app/reticulum_bridge.py new file mode 100644 index 0000000..7ea0f09 --- /dev/null +++ b/app/reticulum_bridge.py @@ -0,0 +1,141 @@ +""" +Reticulum bridge — singleton RNS instance, identity management, topology queries. +""" + +import time +import threading +import logging +import RNS + +from .config import RNS_CONFIG_DIR + +logger = logging.getLogger("rmesh.reticulum") + +_rns_instance: RNS.Reticulum | None = None +_identity: RNS.Identity | None = None +_destination: RNS.Destination | None = None +_start_time: float = 0 +_announced_destinations: dict[str, dict] = {} +_lock = threading.Lock() + +APP_NAME = "rmesh" +ASPECT = "bridge" + + +def init(): + """Initialize the Reticulum instance and server identity.""" + global _rns_instance, _identity, _destination, _start_time + + if _rns_instance is not None: + return + + logger.info("Initializing Reticulum instance from %s", RNS_CONFIG_DIR) + _rns_instance = RNS.Reticulum(configdir=RNS_CONFIG_DIR) + _start_time = time.time() + + # Load or create persistent identity + identity_path = f"{RNS_CONFIG_DIR}/storage/rmesh_identity" + if RNS.Identity.from_file(identity_path) is not None: + _identity = RNS.Identity.from_file(identity_path) + logger.info("Loaded existing identity") + else: + _identity = RNS.Identity() + _identity.to_file(identity_path) + logger.info("Created new identity") + + # Create a destination for this bridge + _destination = RNS.Destination( + _identity, RNS.Destination.IN, RNS.Destination.SINGLE, + APP_NAME, ASPECT, + ) + + # Register announce handler to track network + RNS.Transport.register_announce_handler(_announce_handler) + + logger.info("Reticulum bridge ready — identity: %s", _identity.hexhash) + + +def _announce_handler(destination_hash, announced_identity, app_data): + """Callback when we hear an announce from another node.""" + with _lock: + _announced_destinations[destination_hash.hex()] = { + "destination_hash": destination_hash.hex(), + "app_data": app_data.decode("utf-8") if app_data else None, + "last_heard": time.time(), + } + + +def get_status() -> dict: + """Return transport status info.""" + if _rns_instance is None: + return {"online": False, "transport_enabled": False, "identity_hash": "", + "uptime_seconds": 0, "announced_count": 0, "path_count": 0} + + with _lock: + announced_count = len(_announced_destinations) + + return { + "online": True, + "transport_enabled": getattr(RNS.Transport, "transport_enabled", lambda: False)() if callable(getattr(RNS.Transport, "transport_enabled", None)) else bool(getattr(RNS.Transport, "TRANSPORT", False)), + "identity_hash": _identity.hexhash if _identity else "", + "uptime_seconds": time.time() - _start_time, + "announced_count": announced_count, + "path_count": len(RNS.Transport.destinations) if hasattr(RNS.Transport, "destinations") else 0, + } + + +def get_nodes() -> list[dict]: + """Return list of known announced destinations.""" + with _lock: + return list(_announced_destinations.values()) + + +def get_topology() -> dict: + """Return nodes and links for visualization.""" + nodes = get_nodes() + links = [] + + # Build links from destinations/path tables if available + dest_table = getattr(RNS.Transport, "destinations", {}) + if isinstance(dest_table, (dict, list)): + try: + items = dest_table.items() if isinstance(dest_table, dict) else enumerate(dest_table) + for key, entry in items: + dest_hex = key.hex() if isinstance(key, bytes) else str(key) + hops = entry[2] if isinstance(entry, (list, tuple)) and len(entry) > 2 else 0 + links.append({ + "source": _identity.hexhash if _identity else "", + "target": dest_hex, + "hops": hops, + "active": True, + }) + except Exception: + pass # Transport internals may vary between RNS versions + + return { + "nodes": nodes, + "links": links, + "node_count": len(nodes), + "link_count": len(links), + } + + +def get_identity_info() -> dict: + """Return server identity information.""" + if _identity is None: + return {"identity_hash": "", "public_key_hex": ""} + return { + "identity_hash": _identity.hexhash, + "public_key_hex": _identity.get_public_key().hex() if _identity.get_public_key() else "", + } + + +def announce(): + """Announce this bridge on the network.""" + if _destination is None: + return {"announced": False, "identity_hash": ""} + _destination.announce(app_data=b"rMesh Bridge") + return { + "announced": True, + "identity_hash": _identity.hexhash if _identity else "", + } diff --git a/config/reticulum.conf b/config/reticulum.conf new file mode 100644 index 0000000..463f230 --- /dev/null +++ b/config/reticulum.conf @@ -0,0 +1,57 @@ +[reticulum] + enable_transport = True + share_instance = Yes + shared_instance_port = 37428 + instance_control_port = 37429 + +[logging] + loglevel = 4 + +[interfaces] + [[Default Interface]] + type = TCPServerInterface + interface_enabled = True + listen_ip = 0.0.0.0 + listen_port = 4242 + + # Public transport peers (EU-focused) + [[rtclm.de]] + type = TCPClientInterface + interface_enabled = True + target_host = rtclm.de + target_port = 4242 + + [[mobilefabrik]] + type = TCPClientInterface + interface_enabled = True + target_host = phantom.mobilefabrik.com + target_port = 4242 + + [[Quad4]] + type = TCPClientInterface + interface_enabled = True + target_host = rns.quad4.io + target_port = 4242 + + [[interloper]] + type = TCPClientInterface + interface_enabled = True + target_host = intr.cx + target_port = 4242 + + [[ON6ZQ Belgium]] + type = TCPClientInterface + interface_enabled = True + target_host = reticulum.on6zq.be + target_port = 4965 + + # LoRa hardware (uncomment when RNode is connected) + # [[RNode LoRa]] + # type = RNodeInterface + # interface_enabled = True + # port = /dev/ttyUSB0 + # frequency = 867200000 + # bandwidth = 125000 + # txpower = 7 + # spreadingfactor = 8 + # codingrate = 5 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1ba5e56 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + rmesh-reticulum: + container_name: rmesh-reticulum + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "4242:4242" # Reticulum TCP Server Interface (for Internet backbone peers) + volumes: + - reticulum_data:/data/reticulum + - lxmf_data:/data/lxmf + - meshcore_data:/data/meshcore + # Hardware: uncomment for USB serial to MeshCore/RNode device + # devices: + # - /dev/ttyUSB0:/dev/ttyUSB0 + environment: + - BRIDGE_API_KEY=${BRIDGE_API_KEY} + # Reticulum (Internet backbone) + - RNS_CONFIG_DIR=/data/reticulum + - LXMF_STORAGE_DIR=/data/lxmf + - RNS_LOG_LEVEL=4 + # MeshCore (LoRa mesh) — set MESHCORE_ENABLED=true when companion node is ready + - MESHCORE_ENABLED=${MESHCORE_ENABLED:-false} + - MESHCORE_HOST=${MESHCORE_HOST:-} + - MESHCORE_PORT=${MESHCORE_PORT:-5000} + - MESHCORE_SERIAL=${MESHCORE_SERIAL:-} + - MESHCORE_DATA_DIR=/data/meshcore + networks: + - traefik-public + - rmesh-internal + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + reticulum_data: + lxmf_data: + meshcore_data: + +networks: + traefik-public: + external: true + rmesh-internal: + driver: bridge diff --git a/docs/GATEWAY_SETUP.md b/docs/GATEWAY_SETUP.md new file mode 100644 index 0000000..cfe5037 --- /dev/null +++ b/docs/GATEWAY_SETUP.md @@ -0,0 +1,420 @@ +# rMesh Gateway Setup Guide + +Dual-stack mesh networking: **MeshCore** for LoRa mesh, **Reticulum** for Internet backbone. + +This guide covers: +- Setting up a MeshCore Companion node as your primary LoRa gateway (Part 1-3) +- Optionally adding an RNode for Reticulum Internet bridging (Part 4-5) +- Radio parameters and network architecture (Part 6-7) + +--- + +## Architecture + +``` +[MeshCore Nodes] <--868MHz--> [Companion WiFi] <--TCP:5000--> [rmesh-reticulum] <--TCP:4242--> [Reticulum Internet backbone] +``` + +MeshCore handles the LoRa mesh (structured routing, 64 hops, efficient spectrum use). +Reticulum bridges separate LoRa islands over the Internet. + +--- + +## Part 0: MeshCore Companion WiFi Setup (Primary Gateway) + +This is the recommended primary gateway. A MeshCore Companion node with WiFi +firmware creates a TCP server that rMesh connects to directly. + +### Flash MeshCore Companion WiFi firmware + +1. Open **https://meshcore.io/flasher** in Chrome. +2. Connect a Heltec V3 or T-Beam via USB. +3. Select your device, then **Companion** firmware. +4. Enable WiFi in the configuration (set your SSID + password). +5. Flash. The device creates a TCP server on port 5000. + +### Find the device IP + +After flashing, the device connects to your WiFi. Find its IP via your router's +DHCP client list, or use: +```bash +# If mDNS is available +ping meshcore.local +``` + +### Enable in rMesh + +Edit `/opt/apps/rmesh-reticulum/.env`: +``` +MESHCORE_ENABLED=true +MESHCORE_HOST=192.168.1.xxx # Your companion's IP +MESHCORE_PORT=5000 +``` + +Restart: +```bash +cd /opt/apps/rmesh-reticulum +docker compose up -d +``` + +Check logs: +```bash +docker logs rmesh-reticulum --tail 20 +``` + +You should see: `MeshCore bridge ready — device: YourNodeName` + +### Alternative: USB Serial + +If the companion is plugged directly into the server: +``` +MESHCORE_ENABLED=true +MESHCORE_SERIAL=/dev/ttyUSB0 +``` +And add to docker-compose.yml: +```yaml +devices: + - /dev/ttyUSB0:/dev/ttyUSB0 +``` + +--- + +## Part 0.5: MeshCore Repeaters and Room Servers + +### Flash a Repeater (autonomous relay) + +1. Same web flasher, select **Repeater** instead of Companion. +2. Repeaters are autonomous — no phone or server connection needed. +3. They learn routes and relay traffic between companions. +4. Deploy on rooftops/high points with the solar kit from the BOM. + +### Flash a Room Server (BBS) + +1. Web flasher, select **Room Server**. +2. Room Servers store posts/messages for the community. +3. Users connect to Room Servers through the mesh via their Companion. + +--- + +## Part 1: RNode for Reticulum Internet Backbone (Optional) + +If you want to bridge separate MeshCore mesh islands over the Internet, +add an RNode running Reticulum as a second radio interface. + +> **Note:** RNode and MeshCore are different protocols on different radios. +> They don't interfere — use separate Heltec V3 boards for each. + +## Overview (RNode section) + +``` +[LoRa mesh nodes] <--868 MHz--> [RNode (Heltec V3)] <--USB--> [Server] <--Docker--> [rmesh-reticulum] +``` + +The RNode acts as a radio modem. It converts LoRa radio traffic into serial data +that Reticulum can process. Reticulum running inside the `rmesh-reticulum` container +bridges the radio network with the TCP/IP network. + +--- + +## Part 1: Flash RNode Firmware onto Heltec V3 + +### Prerequisites + +On any Linux/macOS machine with USB: + +```bash +# Install Reticulum (includes rnodeconf tool) +pip install rns --upgrade + +# Linux only: add your user to the serial port group +sudo usermod -a -G dialout $USER +# Log out and back in for this to take effect +``` + +The Heltec V3 uses a CH9102 USB chip. Modern Linux kernels include the driver +(`ch341` module). No extra drivers needed on Linux. On macOS/Windows, install the +CH340/CH9102 driver from the manufacturer. + +### Flash the firmware + +1. Connect the Heltec V3 via USB-C (use a **data** cable, not charge-only). + +2. Run the autoinstaller: + ```bash + rnodeconf --autoinstall + ``` + +3. When prompted: + - Device type: **Heltec LoRa32 v3** + - Frequency band: **868/915/923 MHz** (the SX1262 variant) + +4. The tool downloads and flashes the firmware. Takes about 1-2 minutes. + +### Alternative: Browser-based flasher + +Open **https://liamcottle.github.io/rnode-flasher/** in Chrome/Edge. +Select Heltec LoRa32 V3, 868 MHz. Click flash. Uses WebSerial API. + +### Verify the flash + +```bash +rnodeconf -i /dev/ttyUSB0 +``` + +You should see: firmware version, device signature, hardware platform, and +frequency range. The status LED will flash every ~2 seconds when idle. + +> **Tip:** Find the stable device path for later use: +> ```bash +> ls -la /dev/serial/by-id/ +> ``` +> Use this path instead of `/dev/ttyUSB0` to avoid port renumbering. + +--- + +## Part 2: Connect RNode to the Server + +### Option A: Direct USB to server (simplest) + +If the Heltec V3 is plugged directly into the Netcup server (or a USB-over-IP +adapter), the device appears as `/dev/ttyUSB0` or `/dev/ttyACM0` on the host. + +### Option B: Via Raspberry Pi bridge (remote location) + +If the RNode is at a different location from the server: + +1. Plug RNode into a Raspberry Pi via USB. +2. Install Reticulum on the Pi: `pip install rns` +3. Configure the Pi's Reticulum with: + - An `RNodeInterface` for the radio + - A `TCPClientInterface` pointing to your server's IP on port 4242 +4. The Pi bridges radio traffic to the server over the internet. + +Pi config (`~/.reticulum/config`): +```ini +[reticulum] + enable_transport = True + +[interfaces] + [[RNode LoRa]] + type = RNodeInterface + enabled = yes + port = /dev/ttyUSB0 + frequency = 867200000 + bandwidth = 125000 + txpower = 7 + spreadingfactor = 8 + codingrate = 5 + + [[TCP to Server]] + type = TCPClientInterface + enabled = yes + target_host = YOUR_SERVER_IP + target_port = 4242 +``` + +### Option C: RNode over WiFi/Bluetooth + +RNode supports network connections. After flashing, configure the RNode's WiFi: +```bash +rnodeconf /dev/ttyUSB0 --wifi-ssid "YourNetwork" --wifi-pass "YourPassword" +``` + +Then in Reticulum config, use TCP instead of serial: +```ini +[[RNode LoRa]] + type = RNodeInterface + enabled = yes + port = tcp://rnode-ip-address:0 +``` + +--- + +## Part 3: Enable RNode in rmesh-reticulum Container + +### Step 1: Pass the USB device into Docker + +Edit `/opt/apps/rmesh-reticulum/docker-compose.yml`: + +```yaml +services: + rmesh-reticulum: + # ... existing config ... + devices: + - /dev/ttyUSB0:/dev/ttyUSB0 + # You may also need: + # privileged: true + # Or more targeted: + # cap_add: + # - SYS_RAWIO +``` + +> Use the stable path from `/dev/serial/by-id/` instead of `/dev/ttyUSB0` to +> avoid issues if multiple USB devices are connected. + +### Step 2: Update the Reticulum config + +Edit `/opt/apps/rmesh-reticulum/config/reticulum.conf` — uncomment and configure +the RNode interface: + +```ini +[interfaces] + [[Default Interface]] + type = TCPServerInterface + interface_enabled = True + listen_ip = 0.0.0.0 + listen_port = 4242 + + [[RNode LoRa]] + type = RNodeInterface + interface_enabled = True + port = /dev/ttyUSB0 + frequency = 867200000 + bandwidth = 125000 + txpower = 7 + spreadingfactor = 8 + codingrate = 5 +``` + +### Step 3: Rebuild and restart + +```bash +cd /opt/apps/rmesh-reticulum + +# If this is first time changing the config after initial deploy, +# clear the persisted config so entrypoint.sh copies the new one: +docker compose down +docker volume rm rmesh-reticulum_reticulum_data # WARNING: resets identity +# Or exec into container and update config in-place: +docker exec rmesh-reticulum cp /app/config/reticulum.conf /data/reticulum/config + +docker compose up -d +``` + +### Step 4: Verify + +```bash +docker logs rmesh-reticulum --tail 20 +``` + +You should see the RNode interface initializing alongside the TCP interface. +Check the bridge API: + +```bash +curl -H "X-Bridge-API-Key: YOUR_KEY" http://localhost:8000/api/status +``` + +The `path_count` should increase as radio nodes announce themselves. + +--- + +## Part 4: Radio Parameters + +### Recommended EU settings (868 MHz) + +| Parameter | Value | Notes | +|-----------|-------|-------| +| frequency | 867200000 | 867.2 MHz (EU general) | +| bandwidth | 125000 | 125 kHz — good range/speed balance | +| txpower | 7 | 7 dBm (~5 mW). Safe for EU with any antenna | +| spreadingfactor | 8 | SF8 — balanced range and throughput | +| codingrate | 5 | 4/5 — lowest overhead, fine for low-noise | + +### Tuning for range vs speed + +| Goal | SF | BW | Bitrate | Range | +|------|----|----|---------|-------| +| Maximum speed | 7 | 250000 | ~11 kbps | Shorter | +| Balanced | 8 | 125000 | ~3.1 kbps | Medium | +| Maximum range | 12 | 125000 | ~0.29 kbps | Longest | + +> **All nodes on the same network must use identical radio parameters.** +> Different SF/BW/frequency = invisible to each other. + +### EU duty cycle + +The 868 MHz band has a **1% duty cycle** limit on most sub-bands (ETSI EN 300.220). +Reticulum handles this automatically. With SF8/125kHz, a typical message takes +~100ms on air, allowing ~36 messages per hour within duty cycle. + +The sub-band 869.4-869.65 MHz allows **10% duty cycle** and **500 mW ERP** — useful +for higher-traffic gateways. Set `frequency = 869525000` to use it. + +--- + +## Part 5: Flash MeshCore on a Second Device + +For a companion device that pairs with your phone: + +### Web flasher (easiest) + +1. Open **https://meshcore.io/flasher** in Chrome. +2. Connect a second Heltec V3 via USB. +3. Select: **Heltec V3** → **Companion**. +4. Click flash. Done in under a minute. + +### Companion apps + +After flashing MeshCore Companion firmware, pair with: +- **MeshCore app** (by Liam Cottle) — Android/iOS/Web +- **Web app**: https://app.meshcore.nz + +### MeshCore Repeater + +To flash a dedicated relay node (e.g., the T-Beam for outdoor solar deployment): +1. Same web flasher, select **Repeater** instead of Companion. +2. Repeaters are autonomous — no phone pairing needed. +3. They learn routes and relay traffic between companions. + +--- + +## Part 6: Network Architecture + +Once you have hardware deployed: + +``` + Internet + | + [Netcup Server] + rmesh-reticulum + TCP:4242 + API:8000 + | | + +--------------+ +----------------+ + | | + [RNode Gateway] [Remote Reticulum + Heltec V3 USB nodes via TCP] + 868 MHz LoRa + | + +----------+-----------+ + | | + [MeshCore Companion] [MeshCore Repeater] + Heltec V3 + Phone T-Beam + Solar + | + [More nodes...] +``` + +The RNode gateway bridges LoRa and TCP/IP. MeshCore nodes on the same frequency +are heard by the RNode. Reticulum handles the routing and encryption. + +> **Important:** MeshCore and Reticulum use different protocols at the LoRa level. +> An RNode running Reticulum firmware talks to other Reticulum/RNode devices. +> MeshCore devices talk to other MeshCore devices. To bridge between them, you'd +> need both firmwares running on separate radios, or use the experimental +> Reticulum-over-MeshCore bridge (see GitHub discussions). +> +> For a simple start: use RNode firmware on everything, and run Sideband on phones +> instead of the MeshCore app. This keeps the whole network on one protocol. + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `Permission denied: /dev/ttyUSB0` | `sudo usermod -a -G dialout $USER` then re-login | +| Device not detected | Try a different USB cable (must be data-capable) | +| `rnodeconf` can't find device | Check `ls /dev/tty*` — may be `ttyACM0` instead of `ttyUSB0` | +| RNode flashing fails | Hold BOOT button while plugging in USB, then retry | +| Container can't see device | Add `devices: ["/dev/ttyUSB0:/dev/ttyUSB0"]` to docker-compose.yml | +| No radio peers discovered | Verify all nodes use same frequency/bandwidth/SF | +| Docker volume has old config | `docker exec rmesh-reticulum cp /app/config/reticulum.conf /data/reticulum/config` then restart | diff --git a/docs/HARDWARE_BOM.md b/docs/HARDWARE_BOM.md new file mode 100644 index 0000000..31392ba --- /dev/null +++ b/docs/HARDWARE_BOM.md @@ -0,0 +1,127 @@ +# rMesh Hardware Bill of Materials + +All hardware is **868 MHz** (EU ISM band). Prices are approximate USD as of early 2026. + +## Architecture: MeshCore + Reticulum + +- **MeshCore** = LoRa mesh (structured routing, 64 hops, low power) +- **Reticulum** = Internet backbone (bridges LoRa islands, encrypted) + +Most hardware runs MeshCore. One optional RNode adds Reticulum Internet bridging. + +--- + +## Quick Start (~$60) + +Get two MeshCore nodes talking through rMesh immediately. + +| Qty | Item | Role | Price | +|-----|------|------|-------| +| 2x | Heltec WiFi LoRa 32 V3 (868 MHz) | MeshCore companion + companion | $20 ea | +| 2x | LiPo battery 1000mAh (JST 1.25mm) | Portable power | $5 ea | +| 2x | USB-C data cable | Flashing + gateway link | $5 ea | +| | | **Total** | **~$60** | + +Flash one as a **MeshCore Companion with WiFi** (TCP gateway to rMesh server). +Flash the other as a **MeshCore Companion** and pair with your phone. + +--- + +## Recommended Full Setup (~$236-271) + +### Nodes + +| Qty | Item | Role | Price | Notes | +|-----|------|------|-------|-------| +| 1x | Heltec WiFi LoRa 32 V3 | MeshCore Companion WiFi (TCP gateway) | $20 | Flash via meshcore.io/flasher | +| 1x | LILYGO T-Beam Supreme S3 | MeshCore solar outdoor repeater | $50 | GPS + 18650 holder built-in | +| 1x | LILYGO T-Deck | Standalone MeshCore messenger | $50 | Screen + keyboard, no phone needed | +| 1x | Heltec WiFi LoRa 32 V3 | RNode for Reticulum backbone (optional) | $20 | Flash with `rnodeconf` | + +### Antennas + +| Qty | Item | Role | Price | Notes | +|-----|------|------|-------|-------| +| 1x | Rokland 868 MHz fiberglass omni (5.8 dBi) | Outdoor repeater | $35 | N-type connector, pole/roof mount | +| 2x | SMA stubby 868 MHz | Indoor / gateway | $0 | Included with boards | +| 1x | SMA to N-type pigtail (30cm, LMR-195) | Board to outdoor antenna | $10 | Keep short to reduce signal loss | + +### Solar Repeater Kit + +| Qty | Item | Role | Price | Notes | +|-----|------|------|-------|-------| +| 1x | 6W 5V USB solar panel | Powers T-Beam | $20 | Sufficient for central Europe year-round | +| 1x | Samsung 18650 30Q (3000mAh) | Battery buffer | $7 | ~4 days autonomy without sun | +| 1x | IP65 junction box (100x68x50mm) | Weatherproof enclosure | $8 | Drill for antenna + cable glands | +| 1x | N-type bulkhead connector | Antenna through-wall mount | $6 | | +| 1x | Cable gland set (PG7/PG9) | Seal cable entries | $5 | | + +### Optional + +| Qty | Item | Role | Price | Notes | +|-----|------|------|-------|-------| +| 1x | Raspberry Pi 4/5 | Local RNode host | $45-80 | Skip if server has USB passthrough | +| 1x | 868 MHz Yagi antenna (10-12 dBi) | Point-to-point link | $40 | For bridging distant sites | + +--- + +## Phased Purchasing + +| Phase | Items | Cost | What it enables | +|-------|-------|------|-----------------| +| 1 | 2x Heltec V3 + batteries + cables | ~$60 | Two-node mesh, test rMesh end-to-end | +| 2 | T-Beam + solar + outdoor antenna + enclosure | ~$100 | First outdoor repeater, extends range | +| 3 | T-Deck | ~$50 | Standalone field messaging, no phone | + +--- + +## Board Comparison + +| Board | MCU | LoRa | GPS | Screen | Battery | RNode | MeshCore | Price | +|-------|-----|------|-----|--------|---------|-------|----------|-------| +| Heltec V3 | ESP32-S3 | SX1262 | No | 0.96" OLED | JST LiPo | Yes | Yes | $20 | +| T-Beam Supreme | ESP32-S3 | SX1262 | Yes | None | 18650 holder | Yes | Yes | $50 | +| T-Deck | ESP32-S3 | SX1262 | Optional | 2.8" IPS + touch | LiPo | Limited | Yes | $50 | +| T-Echo | nRF52840 | SX1262 | Yes | 1.54" e-paper | 850mAh | No | Limited | $40 | +| RAK4631 | nRF52840 | SX1262 | Module | Module | Connector | Experimental | Limited | $40 | +| XIAO ESP32-S3 | ESP32-S3 | SX1262 (addon) | No | None | Small LiPo | Yes (DIY) | Community | $18 | + +**For rMesh (Reticulum-first): stick with ESP32-based boards.** RNode firmware targets ESP32. +nRF52-based boards (T-Echo, RAK4631) are primarily Meshtastic. + +--- + +## EU Suppliers + +| Supplier | Ships from | Best for | +|----------|-----------|----------| +| LILYGO Official (AliExpress) | China | T-Beam, T-Deck, T-Echo | +| Heltec Official (AliExpress) | China | Heltec V3 | +| Berrybase.de | Germany | Fast EU, carries Heltec/LILYGO | +| Antratek.nl | Netherlands | Good EU stock | +| Wimo.com | Germany | Antennas | +| Paradar.co.uk | UK | 868 MHz antennas | +| RAKwireless.com | China/EU warehouse | WisBlock modular | +| Amazon.de | EU | Fastest delivery, ~30% markup | + +> Orders from AliExpress over 150 EUR may incur EU customs duties (~20% VAT). + +--- + +## EU Regulatory Notes + +- **Band:** 868 MHz ISM (ETSI EN 300.220) +- **Max power:** 25 mW ERP (14 dBm) with 1% duty cycle on most sub-bands +- **High-gain antennas:** If using >3 dBi antenna, reduce TX power accordingly +- **Never use 915 MHz (US band) hardware in EU** — it's illegal + +--- + +## Power Budget + +A typical LoRa repeater draws ~30 mA average (50-80 mA active, 10-20 mA sleep). + +- Daily consumption: ~720 mAh +- 3000mAh 18650: ~4 days without sun +- 6W panel in central Europe: produces ~1500-3000 mAh/day depending on season +- Year-round self-sustaining with single 18650 buffer diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..37f5325 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# Ensure data directories exist +mkdir -p /data/reticulum/storage +mkdir -p /data/lxmf + +# Copy default config if not present +if [ ! -f /data/reticulum/config ]; then + echo "[entrypoint] Installing default Reticulum config" + cp /app/config/reticulum.conf /data/reticulum/config +fi + +echo "[entrypoint] Starting rMesh Reticulum bridge" +exec "$@" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bc2ef53 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +rns>=0.7.0 +lxmf>=0.4.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +pydantic>=2.0 +meshcore>=2.3.0 diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..49f5a52 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,38 @@ +[supervisord] +nodaemon=true +logfile=/dev/stdout +logfile_maxbytes=0 +user=root + +[program:rnsd] +command=rnsd --config /data/reticulum +autorestart=true +startretries=5 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=10 + +[program:lxmd] +command=lxmd --propagation-node --config /data/lxmf --rnsconfig /data/reticulum +autorestart=true +startretries=5 +startsecs=5 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=20 + +[program:bridge] +command=uvicorn app.main:app --host 0.0.0.0 --port 8000 +directory=/app +autorestart=true +startretries=5 +startsecs=8 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=30