ble-reticulum/examples/ble_dual_node_echo.py

445 lines
13 KiB
Python
Raw Normal View History

2026-05-15 16:19:40 -07:00
#!/usr/bin/env python3
"""
Two-node BLE Reticulum echo proof.
Goal:
Run the same program on zerodev1 and zerodev2. Each node:
- derives its node name from hostname unless --name is supplied
- loads or creates a stable Reticulum app identity
- creates one inbound destination
- announces that destination over configured Reticulum interfaces
- optionally connects to a peer destination hash
- prints every message received over a Reticulum link
After cloning the SD card:
1. Change zerodev2 hostname.
2. Remove cloned Reticulum transport state on zerodev2.
3. Use a unique app identity file per host, or delete the cloned one.
4. Choose BLE mode at runtime, or configure BLEInterface in Reticulum:
--ble-role peripheral # advertise only, accepts incoming BLE connections
--ble-role central # scan/connect only
--ble-role both # scan/connect and advertise
2026-05-15 16:19:40 -07:00
5. Run this program on both nodes.
"""
import argparse
import atexit
import os
import re
import signal
import shutil
import socket
import sys
import tempfile
import threading
import time
APP_NAME = "ble_reticulum_poc"
APP_ASPECT = "echo"
RNS = None
running = True
active_links = {}
active_links_lock = threading.Lock()
temporary_config_dir = None
2026-05-15 16:19:40 -07:00
def log(msg):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def stop(_signum=None, _frame=None):
global running
running = False
def normalise_hex(h):
return h.replace(":", "").replace(" ", "").strip().lower()
def default_identity_path(configdir, node_name):
if configdir is None:
configdir = os.path.expanduser("~/.reticulum")
return os.path.join(configdir, f"{APP_NAME}_{node_name}.identity")
def base_config_dir(configdir):
return os.path.abspath(os.path.expanduser(configdir or "~/.reticulum"))
def str_to_yes_no(value):
if value is None:
return None
value = str(value).strip().lower()
if value in ("yes", "true", "1", "on", "enable", "enabled"):
return "yes"
if value in ("no", "false", "0", "off", "disable", "disabled"):
return "no"
raise argparse.ArgumentTypeError("expected yes/no, true/false, or 1/0")
def requested_ble_overrides(args):
enable_central = args.enable_central
enable_peripheral = args.enable_peripheral
if args.ble_role == "central":
enable_central = "yes"
enable_peripheral = "no"
elif args.ble_role == "peripheral":
enable_central = "no"
enable_peripheral = "yes"
elif args.ble_role == "both":
enable_central = "yes"
enable_peripheral = "yes"
return {
key: value for key, value in {
"enable_central": enable_central,
"enable_peripheral": enable_peripheral,
}.items() if value is not None
}
def find_ble_interface_block(lines):
start = None
for index, line in enumerate(lines):
if re.match(r"^\s*\[\[\s*BLE Interface\s*\]\]\s*$", line):
start = index
break
if start is None:
return None, None
end = len(lines)
for index in range(start + 1, len(lines)):
if re.match(r"^\s*\[\[.*\]\]\s*$", lines[index]) or re.match(r"^\s*\[[^\[].*\]\s*$", lines[index]):
end = index
break
return start, end
def set_config_value(lines, start, end, key, value):
pattern = re.compile(rf"^(\s*{re.escape(key)}\s*=\s*).*$")
for index in range(start + 1, end):
if pattern.match(lines[index]):
lines[index] = f"{key} = {value}\n"
return 0
insert_at = end
lines.insert(insert_at, f"{key} = {value}\n")
return 1
def patch_ble_config(config_path, overrides):
if os.path.isfile(config_path):
with open(config_path, "r", encoding="utf-8") as config_file:
lines = config_file.readlines()
else:
lines = [
"[reticulum]\n",
"enable_transport = No\n",
"share_instance = Yes\n",
"\n",
"[[BLE Interface]]\n",
"type = BLEInterface\n",
"enabled = yes\n",
]
start, end = find_ble_interface_block(lines)
if start is None:
if lines and lines[-1].strip():
lines.append("\n")
start = len(lines)
lines.extend([
"[[BLE Interface]]\n",
"type = BLEInterface\n",
"enabled = yes\n",
])
end = len(lines)
added = 0
for key, value in overrides.items():
added += set_config_value(lines, start, end + added, key, value)
with open(config_path, "w", encoding="utf-8") as config_file:
config_file.writelines(lines)
def runtime_config_dir(args, node_name):
global temporary_config_dir
overrides = requested_ble_overrides(args)
if not overrides:
return args.config
source_dir = base_config_dir(args.config)
temporary_config_dir = tempfile.mkdtemp(prefix=f"{APP_NAME}_{node_name}_")
if os.path.isdir(source_dir):
shutil.copytree(source_dir, temporary_config_dir, dirs_exist_ok=True, symlinks=True)
else:
os.makedirs(temporary_config_dir, exist_ok=True)
patch_ble_config(os.path.join(temporary_config_dir, "config"), overrides)
atexit.register(shutil.rmtree, temporary_config_dir, ignore_errors=True)
return temporary_config_dir
def configure_rns_loglevel(verbosity):
if verbosity is None:
return
levels = {
"critical": "LOG_CRITICAL",
"error": "LOG_ERROR",
"warning": "LOG_WARNING",
"notice": "LOG_NOTICE",
"info": "LOG_INFO",
"verbose": "LOG_VERBOSE",
"debug": "LOG_DEBUG",
"extreme": "LOG_EXTREME",
}
RNS.loglevel(getattr(RNS, levels[verbosity]))
2026-05-15 16:19:40 -07:00
def load_or_create_identity(path):
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
if os.path.isfile(path):
identity = RNS.Identity.from_file(path)
if identity is None:
raise RuntimeError(f"Could not load identity from {path}")
log(f"Loaded identity: {path}")
return identity
identity = RNS.Identity()
if not identity.to_file(path):
raise RuntimeError(f"Could not save new identity to {path}")
os.chmod(path, 0o600)
log(f"Created new identity: {path}")
return identity
def link_key(link):
return RNS.prettyhexrep(link.link_id)
def send_link_packet(link, text):
payload = text.encode("utf-8")
RNS.Packet(link, payload, create_receipt=False).send()
def link_packet_received(message, packet):
try:
text = message.decode("utf-8", errors="replace")
except AttributeError:
text = str(message)
link = getattr(packet, "link", None)
peer = link_key(link) if link else "unknown-link"
log(f"RX link={peer}: {text}")
def link_closed(link):
key = link_key(link)
with active_links_lock:
active_links.pop(key, None)
log(f"Link closed: {key}")
def outbound_link_established(link):
key = link_key(link)
link.set_packet_callback(link_packet_received)
link.set_link_closed_callback(link_closed)
with active_links_lock:
active_links[key] = link
log(f"Outbound link established: {key}")
send_link_packet(link, f"hello from {NODE_NAME}")
def inbound_link_established(link):
key = link_key(link)
link.set_packet_callback(link_packet_received)
link.set_link_closed_callback(link_closed)
with active_links_lock:
active_links[key] = link
log(f"Inbound link established: {key}")
send_link_packet(link, f"hello back from {NODE_NAME}")
def direct_packet_received(data, packet):
text = data.decode("utf-8", errors="replace")
log(f"RX direct packet: {text}")
def announce_loop(destination, interval):
while running:
destination.announce(app_data=NODE_NAME.encode("utf-8"))
log(f"Announced {RNS.prettyhexrep(destination.hash)} as {NODE_NAME}")
for _ in range(interval):
if not running:
break
time.sleep(1)
def heartbeat_loop(interval):
seq = 0
while running:
time.sleep(interval)
seq += 1
with active_links_lock:
links = list(active_links.values())
for link in links:
if link.status == RNS.Link.ACTIVE:
send_link_packet(link, f"heartbeat {seq} from {NODE_NAME}")
def wait_for_path(destination_hash, timeout):
started = time.time()
while running and not RNS.Transport.has_path(destination_hash):
remaining = timeout - (time.time() - started)
if remaining <= 0:
return False
log(f"Requesting path to {RNS.prettyhexrep(destination_hash)}")
RNS.Transport.request_path(destination_hash)
for _ in range(20):
if RNS.Transport.has_path(destination_hash) or not running:
return RNS.Transport.has_path(destination_hash)
time.sleep(0.25)
return RNS.Transport.has_path(destination_hash)
def connect_to_peer(peer_hexhash, timeout):
destination_hash = bytes.fromhex(normalise_hex(peer_hexhash))
if not wait_for_path(destination_hash, timeout):
log(f"No path to peer {RNS.prettyhexrep(destination_hash)} after {timeout}s")
return None
peer_identity = RNS.Identity.recall(destination_hash)
if peer_identity is None:
log(f"Path exists but identity recall failed for {RNS.prettyhexrep(destination_hash)}")
return None
peer_destination = RNS.Destination(
peer_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
APP_NAME,
APP_ASPECT,
)
log(f"Establishing link to {RNS.prettyhexrep(destination_hash)}")
link = RNS.Link(peer_destination)
link.set_link_established_callback(outbound_link_established)
link.set_link_closed_callback(link_closed)
return link
def parse_args():
parser = argparse.ArgumentParser(description="BLE Reticulum two-node echo proof")
parser.add_argument("--config", default=None, help="Reticulum config directory")
parser.add_argument("--identity", default=None, help="App identity file path")
parser.add_argument("--name", default=None, help="Node name, default: hostname")
parser.add_argument("--peer", default=None, help="Peer destination hash to connect to")
parser.add_argument("--announce-interval", type=int, default=30)
parser.add_argument("--send-interval", type=int, default=10)
parser.add_argument("--path-timeout", type=int, default=120)
parser.add_argument(
"--ble-role",
choices=("central", "peripheral", "both"),
default=None,
help="Runtime BLE mode override: central scans/connects, peripheral advertises, both does both",
)
parser.add_argument(
"--enable-central",
"--enable-cental",
type=str_to_yes_no,
default=None,
help="Runtime override for BLEInterface enable_central",
)
parser.add_argument(
"--enable-peripheral",
type=str_to_yes_no,
default=None,
help="Runtime override for BLEInterface enable_peripheral",
)
parser.add_argument(
"--verbosity",
choices=("critical", "error", "warning", "notice", "info", "verbose", "debug", "extreme"),
default=None,
help="Reticulum log verbosity for this run",
)
2026-05-15 16:19:40 -07:00
return parser.parse_args()
if __name__ == "__main__":
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
args = parse_args()
import RNS as rns
RNS = rns
NODE_NAME = args.name or socket.gethostname().split(".")[0]
identity_path = args.identity or default_identity_path(args.config, NODE_NAME)
reticulum_config = runtime_config_dir(args, NODE_NAME)
log(f"Starting node {NODE_NAME}")
log(f"Reticulum config: {args.config or '~/.reticulum'}")
if reticulum_config != args.config:
log(f"Runtime config: {reticulum_config}")
configure_rns_loglevel(args.verbosity)
reticulum = RNS.Reticulum(reticulum_config)
2026-05-15 16:19:40 -07:00
identity = load_or_create_identity(identity_path)
destination = RNS.Destination(
identity,
RNS.Destination.IN,
RNS.Destination.SINGLE,
APP_NAME,
APP_ASPECT,
)
destination.set_link_established_callback(inbound_link_established)
destination.set_packet_callback(direct_packet_received)
destination.set_proof_strategy(RNS.Destination.PROVE_ALL)
log(f"Destination hash: {RNS.prettyhexrep(destination.hash)}")
log("Use this hash as --peer on the other node.")
threading.Thread(target=announce_loop, args=(destination, args.announce_interval), daemon=True).start()
threading.Thread(target=heartbeat_loop, args=(args.send_interval,), daemon=True).start()
if args.peer:
threading.Thread(target=connect_to_peer, args=(args.peer, args.path_timeout), daemon=True).start()
while running:
time.sleep(0.5)
log("Stopping")
sys.exit(0)