from zerodev1

This commit is contained in:
John Poole 2026-05-15 16:19:40 -07:00
commit df090f766b

View file

@ -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)