Compare commits

..

11 commits

Author SHA1 Message Date
76496efcb3 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.
2026-05-16 10:59:02 -07:00
48e9aac047 Adding Perl script to analyze run results, adding feature of message file and determining if Announce needs to be repeated 2026-05-16 10:09:48 -07:00
cd7c41f898 Added sender time stamp 2026-05-16 08:31:39 -07:00
c084e23a9d Merge branch 'dual_node_echo' of https://salemdata.net/repo/jlpoole/ble-reticulum into dual_node_echo
Some checks failed
Tests / Detect Changes (push) Has been cancelled
Tests / Installer Test (Raspberry Pi OS - ARM) (push) Has been cancelled
Tests / Installer Test (Raspberry Pi OS - ARM)-1 (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / Unit Tests-1 (push) Has been cancelled
Tests / Unit Tests-2 (push) Has been cancelled
Tests / Unit Tests-3 (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Integration Tests-1 (push) Has been cancelled
Tests / Integration Tests-2 (push) Has been cancelled
Tests / Integration Tests-3 (push) Has been cancelled
Tests / Installer Test (Fresh System) (push) Has been cancelled
Tests / Installer Test (Fresh System)-1 (push) Has been cancelled
Tests / Installer Test (Fresh System)-2 (push) Has been cancelled
Tests / Installer Test (Fresh System)-3 (push) Has been cancelled
Tests / Installer Test (Fresh System)-4 (push) Has been cancelled
2026-05-16 08:10:25 -07:00
561449e496 added high precision, thousandsth, time precision 2026-05-16 08:09:06 -07:00
887d3dd1e2 time helpers 2026-05-16 08:03:38 -07:00
8b82bfe9ec fixed error on zerodev1 2026-05-15 19:39:12 -07:00
8303ceb626 Resolved merges... hopefully, could not test on jp 2026-05-15 19:29:51 -07:00
cbc1a9cb8e add tmp/ to ignore 2026-05-15 18:58:10 -07:00
d7e03271a4 revised to sent reticulum enable_peripheral and enable_central, also parameter to reduce debugging output 2026-05-15 18:56:09 -07:00
df090f766b from zerodev1 2026-05-15 16:19:40 -07:00
8 changed files with 817 additions and 2 deletions

3
.gitignore vendored
View file

@ -60,3 +60,6 @@ dmypy.json
# OS
Thumbs.db
# temporary run data
tmp/*

591
examples/ble_dual_node_echo.py Executable file
View 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)

View 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
View 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
View 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

View file

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

View file

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

View file

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