feat: Add SovereignRelay Meshtastic-XMPP bridge
This commit introduces a MVP for the sovereign computing hackathon. It provides a Python-based bridge between a local Meshtastic node and an internet-connected XMPP Multi-User Chat room. Features: - `bridge.py`: Uses `meshtastic` and `slixmpp` to bridge messages bidirectionally. - Properly handles asynchronous/synchronous impedance mismatch between XMPP and serial interfaces. - Avoids infinite loop echo chambers. - `flake.nix`: Packages the Python script and its dependencies. - Exposes a NixOS module for configuring the bridge as a persistent systemd service. - Requires `--password-file` to prevent exposing XMPP passwords in process listings. - `README.md`: Explains architecture, usage, and how to configure the NixOS module. Co-authored-by: jamessucla <2191476+jamessucla@users.noreply.github.com>
This commit is contained in:
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# SovereignRelay
|
||||
|
||||
SovereignRelay is an off-grid resilient communication bridge built with NixOS. It connects local Meshtastic LoRa mesh networks to the federated internet via XMPP.
|
||||
|
||||
If the internet goes down, locals can communicate over the Meshtastic LoRa mesh. When the internet is up, a NixOS bridge flawlessly forwards local mesh messages to a federated XMPP Multi-User Chat (MUC) and vice versa, keeping the off-grid community connected to the broader world.
|
||||
|
||||
## Architecture
|
||||
|
||||
* **The Edge:** Local users connected to Meshtastic LoRa radios (e.g., LILYGO T-Beams or RAK WisBlocks).
|
||||
* **The Bridge Hardware:** A machine (like a laptop or Raspberry Pi) running NixOS. A Meshtastic radio connects to it via USB (Serial).
|
||||
* **The Bridge Software:** A Python daemon that actively listens to the Meshtastic serial stream and an XMPP connection.
|
||||
* **The Federated Layer:** XMPP server facilitating connections globally.
|
||||
|
||||
## Prerequisites
|
||||
- A local NixOS installation with flakes enabled.
|
||||
- A Meshtastic device connected via USB to the NixOS machine.
|
||||
- An XMPP account that can join MUCs.
|
||||
|
||||
## Usage
|
||||
|
||||
### Using the Nix Flake directly
|
||||
|
||||
You can run the python bridge straight from the flake:
|
||||
|
||||
```bash
|
||||
nix run . -- -j "your_jid@xmpp.org" -p "your_password" -r "your_room@conference.xmpp.org" -n "meshbridge"
|
||||
```
|
||||
|
||||
### Developing
|
||||
|
||||
You can drop into a Nix shell with all the required python dependencies:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
### NixOS Module (Systemd Service)
|
||||
|
||||
SovereignRelay provides a NixOS module to seamlessly integrate the bridge as a declarative `systemd` service that will persist, automatically start on boot, and autorestart on failure.
|
||||
|
||||
Include the flake in your `flake.nix` inputs:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.sovereign-relay.url = "github:yourusername/sovereign-relay";
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
Then in your NixOS configuration (`configuration.nix` or similar):
|
||||
|
||||
```nix
|
||||
{
|
||||
imports = [
|
||||
inputs.sovereign-relay.nixosModules.default
|
||||
];
|
||||
|
||||
services.sovereign-bridge = {
|
||||
enable = true;
|
||||
jid = "your_jid@xmpp.org";
|
||||
passwordFile = "/run/secrets/xmpp_password";
|
||||
room = "your_room@conference.xmpp.org";
|
||||
nick = "meshbridge";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The bridge daemon requires the `dialout` group to read the serial interface from the Meshtastic USB connection, which is handled automatically by the module's configuration.
|
||||
113
bridge.py
Normal file
113
bridge.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
from pubsub import pub
|
||||
import meshtastic
|
||||
import meshtastic.serial_interface
|
||||
import slixmpp
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)-8s %(message)s')
|
||||
|
||||
class SovereignBridge(slixmpp.ClientXMPP):
|
||||
def __init__(self, jid, password, room, nick):
|
||||
super().__init__(jid, password)
|
||||
self.room = room
|
||||
self.nick = nick
|
||||
self.meshtastic_interface = None
|
||||
|
||||
# Plugins for MUC
|
||||
self.register_plugin('xep_0030') # Service Discovery
|
||||
self.register_plugin('xep_0045') # Multi-User Chat
|
||||
self.register_plugin('xep_0199') # XMPP Ping
|
||||
|
||||
self.add_event_handler("session_start", self.start)
|
||||
self.add_event_handler("groupchat_message", self.muc_message)
|
||||
|
||||
# Setup Meshtastic pubsub listeners
|
||||
pub.subscribe(self.on_meshtastic_receive, "meshtastic.receive.text")
|
||||
|
||||
async def start(self, event):
|
||||
self.send_presence()
|
||||
await self.get_roster()
|
||||
self.plugin['xep_0045'].join_muc(self.room, self.nick)
|
||||
logging.info(f"Joined XMPP MUC: {self.room} as {self.nick}")
|
||||
|
||||
def muc_message(self, msg):
|
||||
# Ignore our own messages to avoid loops
|
||||
if msg['mucnick'] == self.nick:
|
||||
return
|
||||
|
||||
body = msg['body']
|
||||
sender = msg['mucnick']
|
||||
logging.info(f"[XMPP -> Meshtastic] {sender}: {body}")
|
||||
|
||||
if self.meshtastic_interface:
|
||||
# Run the synchronous meshtastic call in a thread pool to avoid blocking asyncio
|
||||
asyncio.ensure_future(
|
||||
asyncio.to_thread(self._send_to_meshtastic, f"{sender}: {body}")
|
||||
)
|
||||
|
||||
def _send_to_meshtastic(self, msg_text):
|
||||
try:
|
||||
self.meshtastic_interface.sendText(msg_text)
|
||||
except Exception as e:
|
||||
logging.error(f"Error sending to Meshtastic: {e}")
|
||||
|
||||
def on_meshtastic_receive(self, packet, interface):
|
||||
try:
|
||||
if 'decoded' in packet and 'text' in packet['decoded']:
|
||||
text = packet['decoded']['text']
|
||||
sender_id = packet.get('fromId', 'Unknown')
|
||||
|
||||
logging.info(f"[Meshtastic -> XMPP] {sender_id}: {text}")
|
||||
|
||||
# Forward to XMPP MUC safely from the meshtastic pubsub thread
|
||||
self.loop.call_soon_threadsafe(self._send_xmpp_message, sender_id, text)
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing Meshtastic packet: {e}")
|
||||
|
||||
def _send_xmpp_message(self, sender_id, text):
|
||||
try:
|
||||
msg = self.make_message(mto=self.room, mbody=f"[{sender_id}] {text}", mtype='groupchat')
|
||||
msg.send()
|
||||
except Exception as e:
|
||||
logging.error(f"Error sending XMPP message: {e}")
|
||||
|
||||
def connect_meshtastic(self):
|
||||
logging.info("Connecting to Meshtastic node...")
|
||||
try:
|
||||
# Connect to the first available serial port
|
||||
self.meshtastic_interface = meshtastic.serial_interface.SerialInterface()
|
||||
logging.info("Connected to Meshtastic node.")
|
||||
except Exception as e:
|
||||
logging.error(f"Could not connect to Meshtastic node: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Meshtastic <-> XMPP Bridge")
|
||||
parser.add_argument("-j", "--jid", required=True, help="JID to use")
|
||||
parser.add_argument("-P", "--password-file", required=False, help="File containing password to use")
|
||||
parser.add_argument("-p", "--password", required=False, help="Password to use")
|
||||
parser.add_argument("-r", "--room", required=True, help="MUC room to join")
|
||||
parser.add_argument("-n", "--nick", required=True, help="MUC nickname")
|
||||
args = parser.parse_args()
|
||||
|
||||
password = args.password
|
||||
if args.password_file:
|
||||
with open(args.password_file, 'r') as f:
|
||||
password = f.read().strip()
|
||||
|
||||
if not password:
|
||||
parser.error("Either --password or --password-file is required")
|
||||
|
||||
xmpp = SovereignBridge(args.jid, password, args.room, args.nick)
|
||||
xmpp.connect_meshtastic()
|
||||
|
||||
logging.info("Connecting to XMPP server...")
|
||||
xmpp.connect()
|
||||
xmpp.process(forever=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
95
flake.nix
Normal file
95
flake.nix
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
description = "SovereignRelay - Meshtastic to XMPP Bridge";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# Define the Python environment with required packages
|
||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||
meshtastic
|
||||
slixmpp
|
||||
]);
|
||||
|
||||
# Package the bridge script
|
||||
sovereign-bridge = pkgs.writeScriptBin "sovereign-bridge" ''
|
||||
#!${pythonEnv}/bin/python
|
||||
${builtins.readFile ./bridge.py}
|
||||
'';
|
||||
|
||||
in
|
||||
{
|
||||
packages.default = sovereign-bridge;
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pythonEnv
|
||||
sovereign-bridge
|
||||
];
|
||||
};
|
||||
}
|
||||
) // {
|
||||
nixosModules.default = { config, lib, pkgs, ... }:
|
||||
with lib;
|
||||
let
|
||||
cfg = config.services.sovereign-bridge;
|
||||
in {
|
||||
options.services.sovereign-bridge = {
|
||||
enable = mkEnableOption "SovereignRelay Bridge";
|
||||
|
||||
jid = mkOption {
|
||||
type = types.str;
|
||||
description = "XMPP JID for the bridge bot";
|
||||
};
|
||||
|
||||
passwordFile = mkOption {
|
||||
type = types.path;
|
||||
description = "Path to file containing XMPP password";
|
||||
};
|
||||
|
||||
room = mkOption {
|
||||
type = types.str;
|
||||
description = "XMPP MUC room to bridge";
|
||||
};
|
||||
|
||||
nick = mkOption {
|
||||
type = types.str;
|
||||
default = "meshbridge";
|
||||
description = "Nickname for the bridge bot in the MUC";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
systemd.services.sovereign-bridge = {
|
||||
description = "SovereignRelay Meshtastic to XMPP Bridge";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = let
|
||||
script = pkgs.writeShellScript "sovereign-bridge-start" ''
|
||||
# Run the bridge
|
||||
${self.packages.${pkgs.system}.default}/bin/sovereign-bridge \
|
||||
-j "${cfg.jid}" \
|
||||
-P "${cfg.passwordFile}" \
|
||||
-r "${cfg.room}" \
|
||||
-n "${cfg.nick}"
|
||||
'';
|
||||
in "${script}";
|
||||
Restart = "always";
|
||||
RestartSec = "10";
|
||||
# Required to access serial ports for Meshtastic
|
||||
SupplementaryGroups = [ "dialout" ];
|
||||
DynamicUser = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user