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:
commit
f04dcbfa79
|
|
@ -0,0 +1 @@
|
|||
BRIDGE_API_KEY=change-me-to-a-secure-random-string
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.egg-info/
|
||||
|
|
@ -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,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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 "",
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue