From df090f766b0c4700518d1a47b460dacc0fdb7488 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 15 May 2026 16:19:40 -0700 Subject: [PATCH 01/10] from zerodev1 --- examples/ble_dual_node_echo.py | 254 +++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 examples/ble_dual_node_echo.py 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) From d7e03271a4be411cbde183e3062687fb3d7006ca Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 15 May 2026 18:56:09 -0700 Subject: [PATCH 02/10] revised to sent reticulum enable_peripheral and enable_central, also parameter to reduce debugging output --- examples/ble_dual_node_echo.py | 220 +++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py index 3057cb4..b75be9e 100644 --- a/examples/ble_dual_node_echo.py +++ b/examples/ble_dual_node_echo.py @@ -15,11 +15,19 @@ 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. +<<<<<<< Updated upstream 4. Configure BLEInterface on both Pis with central+peripheral enabled. +======= + 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 +>>>>>>> Stashed changes 5. Run this program on both nodes. """ import argparse +<<<<<<< Updated upstream import os import signal import socket @@ -35,6 +43,27 @@ APP_ASPECT = "echo" running = True active_links = {} active_links_lock = threading.Lock() +======= +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 +>>>>>>> Stashed changes def log(msg): @@ -56,6 +85,152 @@ def default_identity_path(configdir, node_name): return os.path.join(configdir, f"{APP_NAME}_{node_name}.identity") +<<<<<<< Updated upstream +======= +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])) + + +>>>>>>> Stashed changes def load_or_create_identity(path): os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) @@ -210,6 +385,34 @@ def parse_args(): 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) +<<<<<<< Updated upstream +======= + 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", + ) +>>>>>>> Stashed changes return parser.parse_args() @@ -218,6 +421,7 @@ if __name__ == "__main__": signal.signal(signal.SIGTERM, stop) args = parse_args() +<<<<<<< Updated upstream NODE_NAME = args.name or socket.gethostname().split(".")[0] identity_path = args.identity or default_identity_path(args.config, NODE_NAME) @@ -225,6 +429,22 @@ if __name__ == "__main__": log(f"Reticulum config: {args.config or '~/.reticulum'}") reticulum = RNS.Reticulum(args.config) +======= + 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) +>>>>>>> Stashed changes identity = load_or_create_identity(identity_path) destination = RNS.Destination( From cbc1a9cb8e04fc26e2e5ae102db16ca3ec04e4f5 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 15 May 2026 18:58:10 -0700 Subject: [PATCH 03/10] add tmp/ to ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 43cbac7..4005396 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ dmypy.json # OS Thumbs.db +# temporary run data +tmp/* + From 8303ceb62636e410c1b43eb967097c254b87542b Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 15 May 2026 19:29:51 -0700 Subject: [PATCH 04/10] Resolved merges... hopefully, could not test on jp --- examples/ble_dual_node_echo.py | 43 ++++++---------------------------- 1 file changed, 7 insertions(+), 36 deletions(-) mode change 100644 => 100755 examples/ble_dual_node_echo.py diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py old mode 100644 new mode 100755 index b75be9e..6db6941 --- a/examples/ble_dual_node_echo.py +++ b/examples/ble_dual_node_echo.py @@ -15,35 +15,16 @@ 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. -<<<<<<< Updated upstream - 4. Configure BLEInterface on both Pis with central+peripheral enabled. -======= 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 ->>>>>>> Stashed changes + 5. Run this program on both nodes. """ import argparse -<<<<<<< Updated upstream -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() -======= import atexit import os import re @@ -63,7 +44,7 @@ running = True active_links = {} active_links_lock = threading.Lock() temporary_config_dir = None ->>>>>>> Stashed changes + def log(msg): @@ -85,8 +66,7 @@ def default_identity_path(configdir, node_name): return os.path.join(configdir, f"{APP_NAME}_{node_name}.identity") -<<<<<<< Updated upstream -======= + def base_config_dir(configdir): return os.path.abspath(os.path.expanduser(configdir or "~/.reticulum")) @@ -230,7 +210,7 @@ def configure_rns_loglevel(verbosity): RNS.loglevel(getattr(RNS, levels[verbosity])) ->>>>>>> Stashed changes + def load_or_create_identity(path): os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) @@ -385,8 +365,7 @@ def parse_args(): 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) -<<<<<<< Updated upstream -======= + parser.add_argument( "--ble-role", choices=("central", "peripheral", "both"), @@ -412,7 +391,7 @@ def parse_args(): default=None, help="Reticulum log verbosity for this run", ) ->>>>>>> Stashed changes + return parser.parse_args() @@ -421,15 +400,7 @@ if __name__ == "__main__": signal.signal(signal.SIGTERM, stop) args = parse_args() -<<<<<<< Updated upstream - 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) -======= import RNS as rns RNS = rns @@ -444,7 +415,7 @@ if __name__ == "__main__": configure_rns_loglevel(args.verbosity) reticulum = RNS.Reticulum(reticulum_config) ->>>>>>> Stashed changes + identity = load_or_create_identity(identity_path) destination = RNS.Destination( From 8b82bfe9ec75e83f54f4b6eaea2a84de247a73b0 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 15 May 2026 19:39:12 -0700 Subject: [PATCH 05/10] fixed error on zerodev1 --- examples/ble_dual_node_echo.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py index 6db6941..3d415b6 100755 --- a/examples/ble_dual_node_echo.py +++ b/examples/ble_dual_node_echo.py @@ -207,7 +207,14 @@ def configure_rns_loglevel(verbosity): "extreme": "LOG_EXTREME", } - RNS.loglevel(getattr(RNS, levels[verbosity])) + #RNS.loglevel(getattr(RNS, levels[verbosity])) + level = getattr(RNS, levels[verbosity]) + set_loglevel = getattr(RNS, "loglevel", None) + + if callable(set_loglevel): + set_loglevel(level) + else: + RNS.loglevel = level From 887d3dd1e2bcdb49d8cc57dbd986133cc68c27ba Mon Sep 17 00:00:00 2001 From: John Poole Date: Sat, 16 May 2026 08:03:38 -0700 Subject: [PATCH 06/10] time helpers --- scripts/compare_time.sh | 23 +++++++++++++++++++++++ scripts/compare_time_fast.sh | 16 ++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100755 scripts/compare_time.sh create mode 100755 scripts/compare_time_fast.sh diff --git a/scripts/compare_time.sh b/scripts/compare_time.sh new file mode 100755 index 0000000..0e53941 --- /dev/null +++ b/scripts/compare_time.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# compare_local_time.sh +# 20260516 ChatGPT +# $Header$ +# +# Infinite loop displaying only Local time from timedatectl. +# +# Example: +# chmod 755 compare_local_time.sh +# ./compare_local_time.sh +# +# Suggested side-by-side usage: +# ssh jlpoole@zerodev1 +# ./compare_local_time.sh +# +# ssh jlpoole@zerodev2 +# ./compare_local_time.sh + +while true +do + timedatectl status | grep 'Local time:' + sleep 1 +done diff --git a/scripts/compare_time_fast.sh b/scripts/compare_time_fast.sh new file mode 100755 index 0000000..9d652a1 --- /dev/null +++ b/scripts/compare_time_fast.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# compare_local_time_fast.sh +# 20260516 ChatGPT +# $Header$ +# +# Faster/lighter infinite local time display. +# +# Example: +# chmod 755 compare_local_time_fast.sh +# ./compare_local_time_fast.sh + +while true +do + printf "Local time: %s\n" "$(date '+%Y-%m-%d %H:%M:%S.%3N %Z')" + sleep 0.2 +done From 561449e4963697386f2b9266f0d355bc0dec913c Mon Sep 17 00:00:00 2001 From: John Poole Date: Sat, 16 May 2026 08:09:06 -0700 Subject: [PATCH 07/10] added high precision, thousandsth, time precision --- examples/ble_dual_node_echo.py | 5 ++++- src/ble_reticulum/BLEAgent.py | 8 ++++++-- src/ble_reticulum/BLEGATTServer.py | 6 ++++++ src/ble_reticulum/linux_bluetooth_driver.py | 6 ++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py index 6db6941..03ee4e1 100755 --- a/examples/ble_dual_node_echo.py +++ b/examples/ble_dual_node_echo.py @@ -48,7 +48,10 @@ temporary_config_dir = None def log(msg): - print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + now = time.time() + timestamp = time.strftime("%H:%M:%S", time.localtime(now)) + milliseconds = int((now % 1) * 1000) + print(f"[{timestamp}.{milliseconds:03d}] {msg}", flush=True) def stop(_signum=None, _frame=None): diff --git a/src/ble_reticulum/BLEAgent.py b/src/ble_reticulum/BLEAgent.py index b21e7e6..a05d731 100644 --- a/src/ble_reticulum/BLEAgent.py +++ b/src/ble_reticulum/BLEAgent.py @@ -43,8 +43,12 @@ from dbus.mainloop.glib import DBusGMainLoop import logging from typing import Optional -# Configure logging -logging.basicConfig(level=logging.INFO) +# Configure fallback logging for standalone use without RNS. +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s.%(msecs)03d] [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) logger = logging.getLogger(__name__) diff --git a/src/ble_reticulum/BLEGATTServer.py b/src/ble_reticulum/BLEGATTServer.py index a359342..2a9599d 100644 --- a/src/ble_reticulum/BLEGATTServer.py +++ b/src/ble_reticulum/BLEGATTServer.py @@ -24,6 +24,12 @@ import queue from typing import Any, Dict, Optional, Callable import logging +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s.%(msecs)03d] [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) + try: from bluezero import peripheral, adapter BLUEZERO_AVAILABLE = True diff --git a/src/ble_reticulum/linux_bluetooth_driver.py b/src/ble_reticulum/linux_bluetooth_driver.py index c14bc01..1963229 100644 --- a/src/ble_reticulum/linux_bluetooth_driver.py +++ b/src/ble_reticulum/linux_bluetooth_driver.py @@ -125,6 +125,12 @@ import warnings from typing import Optional, Callable, List, Dict from dataclasses import dataclass +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s.%(msecs)03d] [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) + # Import RNS for logging try: import RNS From cd7c41f8986b6a4c5c4eb95b5c82262bef301ad9 Mon Sep 17 00:00:00 2001 From: John Poole Date: Sat, 16 May 2026 08:31:39 -0700 Subject: [PATCH 08/10] Added sender time stamp --- examples/ble_dual_node_echo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py index 89a2d0d..d992a61 100755 --- a/examples/ble_dual_node_echo.py +++ b/examples/ble_dual_node_echo.py @@ -244,7 +244,7 @@ def link_key(link): def send_link_packet(link, text): - payload = text.encode("utf-8") + payload = f"{text} send_epoch={time.time():.6f}".encode("utf-8") RNS.Packet(link, payload, create_receipt=False).send() From 48e9aac047a867bb2a850fddcb795330b66cb198 Mon Sep 17 00:00:00 2001 From: John Poole Date: Sat, 16 May 2026 10:09:48 -0700 Subject: [PATCH 09/10] Adding Perl script to analyze run results, adding feature of message file and determining if Announce needs to be repeated --- examples/ble_dual_node_echo.py | 124 +++++++++++++++++++- scripts/analyze_reticulum_latency.pl | 166 +++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 6 deletions(-) create mode 100755 scripts/analyze_reticulum_latency.pl diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py index d992a61..38a7298 100755 --- a/examples/ble_dual_node_echo.py +++ b/examples/ble_dual_node_echo.py @@ -44,6 +44,9 @@ running = True active_links = {} active_links_lock = threading.Lock() temporary_config_dir = None +message_file_text = None +message_file_path = None +message_chunk_size = 900 @@ -54,6 +57,23 @@ def log(msg): print(f"[{timestamp}.{milliseconds:03d}] {msg}", flush=True) +def normalise_argv(argv): + normalised = [argv[0]] + key_value_args = { + "message_file": "--message-file", + "message-file": "--message-file", + } + + for arg in argv[1:]: + key, separator, value = arg.partition("=") + if separator and key in key_value_args: + normalised.extend([key_value_args[key], value]) + else: + normalised.append(arg) + + return normalised + + def stop(_signum=None, _frame=None): global running running = False @@ -248,6 +268,63 @@ def send_link_packet(link, text): RNS.Packet(link, payload, create_receipt=False).send() +def active_link_count(): + with active_links_lock: + links = list(active_links.values()) + + return sum(1 for link in links if link.status == RNS.Link.ACTIVE) + + +def load_message_file(path): + expanded_path = os.path.abspath(os.path.expanduser(path)) + with open(expanded_path, "r", encoding="utf-8") as file_handle: + return expanded_path, file_handle.read() + + +def utf8_chunks(text, max_bytes): + chunk = "" + chunk_bytes = 0 + + for character in text: + character_bytes = len(character.encode("utf-8")) + if chunk and chunk_bytes + character_bytes > max_bytes: + yield chunk + chunk = character + chunk_bytes = character_bytes + else: + chunk += character + chunk_bytes += character_bytes + + if chunk: + yield chunk + + +def send_message_file(link): + if message_file_text is None: + return + + chunks = list(utf8_chunks(message_file_text, message_chunk_size)) + total = max(1, len(chunks)) + total_bytes = len(message_file_text.encode("utf-8")) + log(f"Sending file {message_file_path} as {total} chunk(s), {total_bytes} bytes") + + for index, chunk_text in enumerate(chunks): + if not running or link.status != RNS.Link.ACTIVE: + return + + send_link_packet( + link, + f"file_chunk {index + 1}/{total} from {NODE_NAME} " + f"bytes={len(chunk_text.encode('utf-8'))} file={os.path.basename(message_file_path)} data={chunk_text}", + ) + time.sleep(0.1) + + +def start_message_file_sender(link): + if message_file_text is not None: + threading.Thread(target=send_message_file, args=(link,), daemon=True).start() + + def link_packet_received(message, packet): try: text = message.decode("utf-8", errors="replace") @@ -276,6 +353,7 @@ def outbound_link_established(link): log(f"Outbound link established: {key}") send_link_packet(link, f"hello from {NODE_NAME}") + start_message_file_sender(link) def inbound_link_established(link): @@ -288,6 +366,7 @@ def inbound_link_established(link): log(f"Inbound link established: {key}") send_link_packet(link, f"hello back from {NODE_NAME}") + start_message_file_sender(link) def direct_packet_received(data, packet): @@ -295,10 +374,13 @@ def direct_packet_received(data, packet): log(f"RX direct packet: {text}") -def announce_loop(destination, interval): +def announce_loop(destination, interval, only_when_disconnected): while running: - destination.announce(app_data=NODE_NAME.encode("utf-8")) - log(f"Announced {RNS.prettyhexrep(destination.hash)} as {NODE_NAME}") + if only_when_disconnected and active_link_count() > 0: + log("Skipped announce because an active Reticulum link exists") + else: + 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: @@ -375,6 +457,22 @@ def parse_args(): 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( + "--message-file", + default=None, + help="Send this UTF-8 text file once per established link instead of periodic heartbeats", + ) + parser.add_argument( + "--message-chunk-size", + type=int, + default=900, + help="Maximum UTF-8 bytes per file chunk packet", + ) + parser.add_argument( + "--announce-only-when-disconnected", + action="store_true", + help="Skip periodic announces while at least one Reticulum link is active", + ) parser.add_argument( "--ble-role", @@ -402,7 +500,10 @@ def parse_args(): help="Reticulum log verbosity for this run", ) - return parser.parse_args() + args = parser.parse_args(normalise_argv(sys.argv)[1:]) + if args.message_chunk_size < 1: + parser.error("--message-chunk-size must be at least 1") + return args if __name__ == "__main__": @@ -417,6 +518,9 @@ if __name__ == "__main__": 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) + message_chunk_size = args.message_chunk_size + if args.message_file: + message_file_path, message_file_text = load_message_file(args.message_file) log(f"Starting node {NODE_NAME}") log(f"Reticulum config: {args.config or '~/.reticulum'}") @@ -441,9 +545,17 @@ if __name__ == "__main__": log(f"Destination hash: {RNS.prettyhexrep(destination.hash)}") log("Use this hash as --peer on the other node.") + if message_file_text is not None: + log(f"Message file mode: {message_file_path} ({len(message_file_text.encode('utf-8'))} bytes)") + log("Periodic heartbeats are disabled in message file mode.") - 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() + threading.Thread( + target=announce_loop, + args=(destination, args.announce_interval, args.announce_only_when_disconnected), + daemon=True, + ).start() + if message_file_text is None: + 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() diff --git a/scripts/analyze_reticulum_latency.pl b/scripts/analyze_reticulum_latency.pl new file mode 100755 index 0000000..8001a7b --- /dev/null +++ b/scripts/analyze_reticulum_latency.pl @@ -0,0 +1,166 @@ +#!/usr/bin/env perl +# ./analyze_reticulum_latency_20260516_0842.pl 20250516_0836_zerodev1.txt 20250516_0836_zerodev2.txt +# chmod 755 analyze_reticulum_latency_20260516_0842.pl +# 2026-05-16 ChatGPT +# $Header$ +# $HeadURL$ + +use strict; +use warnings; +use POSIX qw(strftime); +use Time::Local qw(timegm); +use List::Util qw(min max sum); + +my $usage = "Usage: $0 node1.log node2.log [more.log ...]\n"; +@ARGV >= 2 or die $usage; + +my %mon = ( + Jan => 0, Feb => 1, Mar => 2, Apr => 3, May => 4, Jun => 5, + Jul => 6, Aug => 7, Sep => 8, Oct => 9, Nov => 10, Dec => 11, +); + +my %tz_offset = ( + UTC => 0, + GMT => 0, + PST => -8 * 3600, + PDT => -7 * 3600, +); + +my @rows; +my %by_dir; + +for my $file (@ARGV) { + open my $fh, '<', $file or die "Cannot open $file: $!\n"; + + my $first = <$fh>; + defined $first or die "Empty file: $file\n"; + chomp $first; + + my ($year, $month, $day, $tz) = parse_log_date($first); + my $receiver = receiver_from_filename($file); + + while (my $line = <$fh>) { + chomp $line; + + # Example: + # [08:33:11.753] RX link=<...>: hello from zerodev2 send_epoch=1778945591.644050 + next unless $line =~ /^\[(\d\d):(\d\d):(\d\d)\.(\d{3})\]\s+RX\s+.*?:\s+(.+?)\s+from\s+(\S+)\s+send_epoch=([0-9]+(?:\.[0-9]+)?)/; + + my ($hh, $mi, $ss, $ms, $message, $sender, $send_epoch) = ($1, $2, $3, $4, $5, $6, $7); + + my $recv_epoch = epoch_from_local_parts($year, $month, $day, $hh, $mi, $ss, $ms, $tz); + my $latency = $recv_epoch - $send_epoch; + + my $row = { + file => $file, + sender => $sender, + receiver => $receiver, + message => $message, + send_epoch => $send_epoch + 0.0, + recv_epoch => $recv_epoch + 0.0, + latency => $latency + 0.0, + line => $line, + }; + + push @rows, $row; + push @{ $by_dir{"$sender->$receiver"} }, $row; + } + + close $fh; +} + +if (!@rows) { + die "No RX lines with send_epoch= were found. Add send_epoch to the payload before running this analyzer.\n"; +} + +print "Reticulum BLE latency analysis\n"; +print "Generated: ", strftime('%Y-%m-%d %H:%M:%S %Z', localtime), "\n"; +print "Input files:\n"; +print " $_\n" for @ARGV; +print "\n"; + +for my $dir (sort keys %by_dir) { + my @lat = map { $_->{latency} } @{ $by_dir{$dir} }; + my $n = scalar @lat; + my $mean = sum(@lat) / $n; + my $median = percentile(50, @lat); + my $p95 = percentile(95, @lat); + my $stddev = stddev(@lat); + + printf "Direction: %s\n", $dir; + printf " samples : %d\n", $n; + printf " min : %.6f s %.3f ms\n", min(@lat), min(@lat) * 1000.0; + printf " median : %.6f s %.3f ms\n", $median, $median * 1000.0; + printf " mean : %.6f s %.3f ms\n", $mean, $mean * 1000.0; + printf " p95 : %.6f s %.3f ms\n", $p95, $p95 * 1000.0; + printf " max : %.6f s %.3f ms\n", max(@lat), max(@lat) * 1000.0; + printf " stddev : %.6f s %.3f ms\n", $stddev, $stddev * 1000.0; + print "\n"; +} + +print "Per-message detail:\n"; +printf "%10s %-10s %-10s %-18s %12s\n", 'lat_ms', 'sender', 'receiver', 'message', 'recv_minus_send'; +for my $r (sort { $a->{recv_epoch} <=> $b->{recv_epoch} } @rows) { + printf "%10.3f %-10s %-10s %-18s %12.6f\n", + $r->{latency} * 1000.0, + $r->{sender}, + $r->{receiver}, + $r->{message}, + $r->{latency}; +} + +print "\n"; +print "Caution: one-way latency assumes sender and receiver clocks are synchronized.\n"; +print "For tighter measurement, include chronyc tracking output near the run, or use echo/ACK round-trip timestamps.\n"; + +sub parse_log_date { + my ($line) = @_; + + # Supports: + # Sat May 16 08:32:49 PDT 2026 + # Sat May 16 08:32:51 AM PDT 2026 + if ($line =~ /^\S+\s+(\S+)\s+(\d{1,2})\s+\d\d:\d\d:\d\d(?:\s+(?:AM|PM))?\s+(\S+)\s+(\d{4})/i) { + my ($mon_name, $day, $tz, $year) = ($1, $2, uc($3), $4); + exists $mon{$mon_name} or die "Cannot parse month '$mon_name' in: $line\n"; + exists $tz_offset{$tz} or die "Unknown timezone '$tz' in: $line\nAdd it to %tz_offset.\n"; + return ($year, $mon{$mon_name}, $day, $tz); + } + + die "Cannot parse log date from first line: $line\n"; +} + +sub epoch_from_local_parts { + my ($year, $month, $day, $hh, $mi, $ss, $ms, $tz) = @_; + my $epoch_as_if_utc = timegm($ss, $mi, $hh, $day, $month, $year - 1900); + return $epoch_as_if_utc - $tz_offset{$tz} + ($ms / 1000.0); +} + +sub receiver_from_filename { + my ($file) = @_; + return $1 if $file =~ /(zerodev\d+)/; + return $file; +} + +sub percentile { + my ($p, @values) = @_; + @values = sort { $a <=> $b } @values; + return undef unless @values; + return $values[0] if @values == 1; + + my $rank = ($p / 100.0) * (@values - 1); + my $lo = int($rank); + my $hi = $lo + 1; + return $values[$lo] if $hi > $#values; + + my $frac = $rank - $lo; + return $values[$lo] + (($values[$hi] - $values[$lo]) * $frac); +} + +sub stddev { + my (@values) = @_; + return 0 if @values < 2; + my $mean = sum(@values) / @values; + my $ss = 0; + $ss += ($_ - $mean) ** 2 for @values; + return sqrt($ss / @values); +} From 76496efcb3bb59e726f8895f9dcbfcb82047f882 Mon Sep 17 00:00:00 2001 From: John Poole Date: Sat, 16 May 2026 10:59:02 -0700 Subject: [PATCH 10/10] Default message chunk size is now 300, not 900. --message-chunk-size is now treated as a requested maximum. If the requested value is too large for the Reticulum link budget, the program caps it and logs that it did so. The cap accounts for file metadata and send_epoch. --- examples/ble_dual_node_echo.py | 44 ++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py index 38a7298..e441c97 100755 --- a/examples/ble_dual_node_echo.py +++ b/examples/ble_dual_node_echo.py @@ -38,6 +38,8 @@ import time APP_NAME = "ble_reticulum_poc" APP_ASPECT = "echo" +DEFAULT_MESSAGE_CHUNK_SIZE = 300 +LINK_PAYLOAD_BUDGET = 420 RNS = None running = True @@ -46,7 +48,7 @@ active_links_lock = threading.Lock() temporary_config_dir = None message_file_text = None message_file_path = None -message_chunk_size = 900 +message_chunk_size = DEFAULT_MESSAGE_CHUNK_SIZE @@ -299,24 +301,46 @@ def utf8_chunks(text, max_bytes): yield chunk +def file_chunk_prefix(index, total, chunk_bytes): + return ( + f"file_chunk {index}/{total} from {NODE_NAME} " + f"bytes={chunk_bytes} file={os.path.basename(message_file_path)} data=" + ) + + +def file_data_chunk_size(total, total_bytes): + prefix = file_chunk_prefix(total, total, total_bytes) + send_epoch_suffix = " send_epoch=1778951576.861234" + metadata_bytes = len(prefix.encode("utf-8")) + len(send_epoch_suffix.encode("utf-8")) + safe_size = max(1, LINK_PAYLOAD_BUDGET - metadata_bytes) + return min(message_chunk_size, safe_size) + + def send_message_file(link): if message_file_text is None: return - chunks = list(utf8_chunks(message_file_text, message_chunk_size)) - total = max(1, len(chunks)) total_bytes = len(message_file_text.encode("utf-8")) + estimated_total = max(1, (total_bytes + message_chunk_size - 1) // message_chunk_size) + data_chunk_size = file_data_chunk_size(estimated_total, total_bytes) + chunks = list(utf8_chunks(message_file_text, data_chunk_size)) + total = max(1, len(chunks)) + data_chunk_size = file_data_chunk_size(total, total_bytes) + chunks = list(utf8_chunks(message_file_text, data_chunk_size)) + total = max(1, len(chunks)) + if data_chunk_size < message_chunk_size: + log( + f"Requested message chunk size {message_chunk_size} exceeds Reticulum link budget; " + f"using {data_chunk_size} data bytes per chunk" + ) log(f"Sending file {message_file_path} as {total} chunk(s), {total_bytes} bytes") for index, chunk_text in enumerate(chunks): if not running or link.status != RNS.Link.ACTIVE: return - send_link_packet( - link, - f"file_chunk {index + 1}/{total} from {NODE_NAME} " - f"bytes={len(chunk_text.encode('utf-8'))} file={os.path.basename(message_file_path)} data={chunk_text}", - ) + chunk_bytes = len(chunk_text.encode("utf-8")) + send_link_packet(link, f"{file_chunk_prefix(index + 1, total, chunk_bytes)}{chunk_text}") time.sleep(0.1) @@ -465,8 +489,8 @@ def parse_args(): parser.add_argument( "--message-chunk-size", type=int, - default=900, - help="Maximum UTF-8 bytes per file chunk packet", + default=DEFAULT_MESSAGE_CHUNK_SIZE, + help="Requested maximum UTF-8 data bytes per file chunk packet", ) parser.add_argument( "--announce-only-when-disconnected",