diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py new file mode 100644 index 0000000..3057cb4 --- /dev/null +++ b/examples/ble_dual_node_echo.py @@ -0,0 +1,254 @@ +#!/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)