Compare commits
11 commits
main
...
dual_node_
| Author | SHA1 | Date | |
|---|---|---|---|
| 76496efcb3 | |||
| 48e9aac047 | |||
| cd7c41f898 | |||
| c084e23a9d | |||
| 561449e496 | |||
| 887d3dd1e2 | |||
| 8b82bfe9ec | |||
| 8303ceb626 | |||
| cbc1a9cb8e | |||
| d7e03271a4 | |||
| df090f766b |
8 changed files with 817 additions and 2 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -60,3 +60,6 @@ dmypy.json
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
# temporary run data
|
||||||
|
tmp/*
|
||||||
|
|
||||||
|
|
|
||||||
591
examples/ble_dual_node_echo.py
Executable file
591
examples/ble_dual_node_echo.py
Executable file
|
|
@ -0,0 +1,591 @@
|
||||||
|
#!/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
|
||||||
|
|
||||||
|
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"
|
||||||
|
DEFAULT_MESSAGE_CHUNK_SIZE = 300
|
||||||
|
LINK_PAYLOAD_BUDGET = 420
|
||||||
|
|
||||||
|
RNS = None
|
||||||
|
running = True
|
||||||
|
active_links = {}
|
||||||
|
active_links_lock = threading.Lock()
|
||||||
|
temporary_config_dir = None
|
||||||
|
message_file_text = None
|
||||||
|
message_file_path = None
|
||||||
|
message_chunk_size = DEFAULT_MESSAGE_CHUNK_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
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 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
|
||||||
|
|
||||||
|
|
||||||
|
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]))
|
||||||
|
level = getattr(RNS, levels[verbosity])
|
||||||
|
set_loglevel = getattr(RNS, "loglevel", None)
|
||||||
|
|
||||||
|
if callable(set_loglevel):
|
||||||
|
set_loglevel(level)
|
||||||
|
else:
|
||||||
|
RNS.loglevel = level
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 = f"{text} send_epoch={time.time():.6f}".encode("utf-8")
|
||||||
|
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 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
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}")
|
||||||
|
start_message_file_sender(link)
|
||||||
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
start_message_file_sender(link)
|
||||||
|
|
||||||
|
|
||||||
|
def direct_packet_received(data, packet):
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
log(f"RX direct packet: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def announce_loop(destination, interval, only_when_disconnected):
|
||||||
|
while running:
|
||||||
|
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:
|
||||||
|
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(
|
||||||
|
"--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=DEFAULT_MESSAGE_CHUNK_SIZE,
|
||||||
|
help="Requested maximum UTF-8 data 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",
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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__":
|
||||||
|
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)
|
||||||
|
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'}")
|
||||||
|
if reticulum_config != args.config:
|
||||||
|
log(f"Runtime config: {reticulum_config}")
|
||||||
|
configure_rns_loglevel(args.verbosity)
|
||||||
|
|
||||||
|
reticulum = RNS.Reticulum(reticulum_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.")
|
||||||
|
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, 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()
|
||||||
|
|
||||||
|
while running:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
log("Stopping")
|
||||||
|
sys.exit(0)
|
||||||
166
scripts/analyze_reticulum_latency.pl
Executable file
166
scripts/analyze_reticulum_latency.pl
Executable file
|
|
@ -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);
|
||||||
|
}
|
||||||
23
scripts/compare_time.sh
Executable file
23
scripts/compare_time.sh
Executable file
|
|
@ -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
|
||||||
16
scripts/compare_time_fast.sh
Executable file
16
scripts/compare_time_fast.sh
Executable file
|
|
@ -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
|
||||||
|
|
@ -43,8 +43,12 @@ from dbus.mainloop.glib import DBusGMainLoop
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
# Configure logging
|
# Configure fallback logging for standalone use without RNS.
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ import queue
|
||||||
from typing import Any, Dict, Optional, Callable
|
from typing import Any, Dict, Optional, Callable
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bluezero import peripheral, adapter
|
from bluezero import peripheral, adapter
|
||||||
BLUEZERO_AVAILABLE = True
|
BLUEZERO_AVAILABLE = True
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,12 @@ import warnings
|
||||||
from typing import Optional, Callable, List, Dict
|
from typing import Optional, Callable, List, Dict
|
||||||
from dataclasses import dataclass
|
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
|
# Import RNS for logging
|
||||||
try:
|
try:
|
||||||
import RNS
|
import RNS
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue