#!/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. Configure BLEInterface on both Pis with central+peripheral enabled. 5. Run this program on both nodes. """ import argparse import os import signal import socket import sys import threading import time import RNS APP_NAME = "ble_reticulum_poc" APP_ASPECT = "echo" running = True active_links = {} active_links_lock = threading.Lock() 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 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) return parser.parse_args() if __name__ == "__main__": signal.signal(signal.SIGINT, stop) signal.signal(signal.SIGTERM, stop) args = parse_args() NODE_NAME = args.name or socket.gethostname().split(".")[0] identity_path = args.identity or default_identity_path(args.config, NODE_NAME) log(f"Starting node {NODE_NAME}") log(f"Reticulum config: {args.config or '~/.reticulum'}") reticulum = RNS.Reticulum(args.config) 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)