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) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-06 16:42:10 +00:00
commit f04dcbfa79
17 changed files with 1769 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
BRIDGE_API_KEY=change-me-to-a-secure-random-string

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.pyc
.env
*.egg-info/

25
Dockerfile Normal file
View File

@ -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"]

0
app/__init__.py Normal file
View File

15
app/config.py Normal file
View File

@ -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")

204
app/lxmf_bridge.py Normal file
View File

@ -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)

240
app/main.py Normal file
View File

@ -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()

348
app/meshcore_bridge.py Normal file
View File

@ -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)

74
app/models.py Normal file
View File

@ -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"

141
app/reticulum_bridge.py Normal file
View File

@ -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 "",
}

57
config/reticulum.conf Normal file
View File

@ -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

54
docker-compose.yml Normal file
View File

@ -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

420
docs/GATEWAY_SETUP.md Normal file
View File

@ -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 |

127
docs/HARDWARE_BOM.md Normal file
View File

@ -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

15
entrypoint.sh Normal file
View File

@ -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 "$@"

6
requirements.txt Normal file
View File

@ -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

38
supervisord.conf Normal file
View File

@ -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