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/* + diff --git a/examples/ble_dual_node_echo.py b/examples/ble_dual_node_echo.py new file mode 100755 index 0000000..e441c97 --- /dev/null +++ b/examples/ble_dual_node_echo.py @@ -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) 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); +} 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 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