__init__.py
1 # Reticulum License 2 # 3 # Copyright (c) 2016-2025 Mark Qvist 4 # 5 # Permission is hereby granted, free of charge, to any person obtaining a copy 6 # of this software and associated documentation files (the "Software"), to deal 7 # in the Software without restriction, including without limitation the rights 8 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 # copies of the Software, and to permit persons to whom the Software is 10 # furnished to do so, subject to the following conditions: 11 # 12 # - The Software shall not be used in any kind of system which includes amongst 13 # its functions the ability to purposefully do harm to human beings. 14 # 15 # - The Software shall not be used, directly or indirectly, in the creation of 16 # an artificial intelligence, machine learning or language model training 17 # dataset, including but not limited to any use that contributes to the 18 # training or development of such a model or algorithm. 19 # 20 # - The above copyright notice and this permission notice shall be included in 21 # all copies or substantial portions of the Software. 22 # 23 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 # SOFTWARE. 30 31 import os 32 import sys 33 import glob 34 import time 35 import datetime 36 import random 37 import threading 38 39 from ._version import __version__ 40 41 from .Reticulum import Reticulum 42 from .Identity import Identity 43 from .Link import Link, RequestReceipt 44 from .Channel import MessageBase 45 from .Buffer import Buffer, RawChannelReader, RawChannelWriter 46 from .Transport import Transport 47 from .Discovery import InterfaceAnnouncer 48 from .Destination import Destination 49 from .Packet import Packet 50 from .Packet import PacketReceipt 51 from .Resolver import Resolver 52 from .Resource import Resource, ResourceAdvertisement 53 from .Cryptography import HKDF 54 from .Cryptography import Hashes 55 56 py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") 57 pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") 58 modules = py_modules+pyc_modules 59 __all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) 60 61 import importlib.util 62 if importlib.util.find_spec("cython"): import cython; compiled = cython.compiled 63 else: compiled = False 64 65 LOG_NONE = -1 66 LOG_CRITICAL = 0 67 LOG_ERROR = 1 68 LOG_WARNING = 2 69 LOG_NOTICE = 3 70 LOG_INFO = 4 71 LOG_VERBOSE = 5 72 LOG_DEBUG = 6 73 LOG_EXTREME = 7 74 75 LOG_STDOUT = 0x91 76 LOG_FILE = 0x92 77 LOG_CALLBACK = 0x93 78 79 LOG_MAXSIZE = 5*1024*1024 80 81 loglevel = LOG_NOTICE 82 logfile = None 83 logdest = LOG_STDOUT 84 logcall = None 85 logtimefmt = "%Y-%m-%d %H:%M:%S" 86 logtimefmt_p = "%H:%M:%S.%f" 87 compact_log_fmt = False 88 89 instance_random = random.Random() 90 instance_random.seed(os.urandom(10)) 91 92 _always_override_destination = False 93 94 logging_lock = threading.Lock() 95 96 def loglevelname(level): 97 if (level == LOG_CRITICAL): 98 return "[Critical]" 99 if (level == LOG_ERROR): 100 return "[Error] " 101 if (level == LOG_WARNING): 102 return "[Warning] " 103 if (level == LOG_NOTICE): 104 return "[Notice] " 105 if (level == LOG_INFO): 106 return "[Info] " 107 if (level == LOG_VERBOSE): 108 return "[Verbose] " 109 if (level == LOG_DEBUG): 110 return "[Debug] " 111 if (level == LOG_EXTREME): 112 return "[Extra] " 113 114 return "Unknown" 115 116 def version(): 117 return __version__ 118 119 def host_os(): 120 from .vendor.platformutils import get_platform 121 return get_platform() 122 123 def timestamp_str(time_s): 124 timestamp = time.localtime(time_s) 125 return time.strftime(logtimefmt, timestamp) 126 127 def precise_timestamp_str(time_s): 128 return datetime.datetime.now().strftime(logtimefmt_p)[:-3] 129 130 def log(msg, level=3, _override_destination = False, pt=False): 131 if loglevel == LOG_NONE: return 132 global _always_override_destination, compact_log_fmt 133 msg = str(msg) 134 if loglevel >= level: 135 if pt: 136 logstring = "["+precise_timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg 137 else: 138 if not compact_log_fmt: 139 logstring = "["+timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg 140 else: 141 logstring = "["+timestamp_str(time.time())+"] "+msg 142 143 with logging_lock: 144 if (logdest == LOG_STDOUT or _always_override_destination or _override_destination): 145 if not threading.main_thread().is_alive(): return 146 else: 147 try: print(logstring) 148 except: pass 149 150 elif (logdest == LOG_FILE and logfile != None): 151 try: 152 file = open(logfile, "a") 153 file.write(logstring+"\n") 154 file.close() 155 156 if os.path.getsize(logfile) > LOG_MAXSIZE: 157 prevfile = logfile+".1" 158 if os.path.isfile(prevfile): 159 os.unlink(prevfile) 160 os.rename(logfile, prevfile) 161 162 except Exception as e: 163 _always_override_destination = True 164 log("Exception occurred while writing log message to log file: "+str(e), LOG_CRITICAL) 165 log("Dumping future log events to console!", LOG_CRITICAL) 166 log(msg, level) 167 168 elif logdest == LOG_CALLBACK: 169 try: 170 logcall(logstring) 171 except Exception as e: 172 _always_override_destination = True 173 log("Exception occurred while calling external log handler: "+str(e), LOG_CRITICAL) 174 log("Dumping future log events to console!", LOG_CRITICAL) 175 log(msg, level) 176 177 178 def rand(): 179 result = instance_random.random() 180 return result 181 182 def trace_exception(e): 183 import traceback 184 exception_info = "".join(traceback.TracebackException.from_exception(e).format()) 185 log(f"An unhandled {str(type(e))} exception occurred: {str(e)}", LOG_ERROR) 186 log(exception_info, LOG_ERROR) 187 188 def hexrep(data, delimit=True): 189 try: 190 iter(data) 191 except TypeError: 192 data = [data] 193 194 delimiter = ":" 195 if not delimit: 196 delimiter = "" 197 hexrep = delimiter.join("{:02x}".format(c) for c in data) 198 return hexrep 199 200 def prettyhexrep(data): 201 delimiter = "" 202 hexrep = "<"+delimiter.join("{:02x}".format(c) for c in data)+">" 203 return hexrep 204 205 def prettyspeed(num, suffix="b"): 206 return prettysize(num/8, suffix=suffix)+"ps" 207 208 def prettysize(num, suffix='B'): 209 units = ['','K','M','G','T','P','E','Z'] 210 last_unit = 'Y' 211 212 if suffix == 'b': 213 num *= 8 214 units = ['','K','M','G','T','P','E','Z'] 215 last_unit = 'Y' 216 217 for unit in units: 218 if abs(num) < 1000.0: 219 if unit == "": 220 return "%.0f %s%s" % (num, unit, suffix) 221 else: 222 return "%.2f %s%s" % (num, unit, suffix) 223 num /= 1000.0 224 225 return "%.2f%s%s" % (num, last_unit, suffix) 226 227 def prettyfrequency(hz, suffix="Hz"): 228 num = hz*1e6 229 units = ["µ", "m", "", "K","M","G","T","P","E","Z"] 230 last_unit = "Y" 231 232 for unit in units: 233 if abs(num) < 1000.0: 234 return "%.2f %s%s" % (num, unit, suffix) 235 num /= 1000.0 236 237 return "%.2f%s%s" % (num, last_unit, suffix) 238 239 def prettydistance(m, suffix="m"): 240 num = m*1e6 241 units = ["µ", "m", "c", ""] 242 last_unit = "K" 243 244 for unit in units: 245 divisor = 1000.0 246 if unit == "m": divisor = 10 247 if unit == "c": divisor = 100 248 249 if abs(num) < divisor: 250 return "%.2f %s%s" % (num, unit, suffix) 251 num /= divisor 252 253 return "%.2f %s%s" % (num, last_unit, suffix) 254 255 def prettytime(time, verbose=False, compact=False): 256 neg = False 257 if time < 0: 258 time = abs(time) 259 neg = True 260 261 days = int(time // (24 * 3600)) 262 time = time % (24 * 3600) 263 hours = int(time // 3600) 264 time %= 3600 265 minutes = int(time // 60) 266 time %= 60 267 if compact: 268 seconds = int(time) 269 else: 270 seconds = round(time, 2) 271 272 ss = "" if seconds == 1 else "s" 273 sm = "" if minutes == 1 else "s" 274 sh = "" if hours == 1 else "s" 275 sd = "" if days == 1 else "s" 276 277 displayed = 0 278 components = [] 279 if days > 0 and ((not compact) or displayed < 2): 280 components.append(str(days)+" day"+sd if verbose else str(days)+"d") 281 displayed += 1 282 283 if hours > 0 and ((not compact) or displayed < 2): 284 components.append(str(hours)+" hour"+sh if verbose else str(hours)+"h") 285 displayed += 1 286 287 if minutes > 0 and ((not compact) or displayed < 2): 288 components.append(str(minutes)+" minute"+sm if verbose else str(minutes)+"m") 289 displayed += 1 290 291 if seconds > 0 and ((not compact) or displayed < 2): 292 components.append(str(seconds)+" second"+ss if verbose else str(seconds)+"s") 293 displayed += 1 294 295 i = 0 296 tstr = "" 297 for c in components: 298 i += 1 299 if i == 1: 300 pass 301 elif i < len(components): 302 tstr += ", " 303 elif i == len(components): 304 tstr += " and " 305 306 tstr += c 307 308 if tstr == "": 309 return "0s" 310 else: 311 if not neg: 312 return tstr 313 else: 314 return f"-{tstr}" 315 316 def prettyshorttime(time, verbose=False, compact=False): 317 neg = False 318 time = time*1e6 319 if time < 0: 320 time = abs(time) 321 neg = True 322 323 seconds = int(time // 1e6); time %= 1e6 324 milliseconds = int(time // 1e3); time %= 1e3 325 326 if compact: 327 microseconds = int(time) 328 else: 329 microseconds = round(time, 2) 330 331 ss = "" if seconds == 1 else "s" 332 sms = "" if milliseconds == 1 else "s" 333 sus = "" if microseconds == 1 else "s" 334 335 displayed = 0 336 components = [] 337 if seconds > 0 and ((not compact) or displayed < 2): 338 components.append(str(seconds)+" second"+ss if verbose else str(seconds)+"s") 339 displayed += 1 340 341 if milliseconds > 0 and ((not compact) or displayed < 2): 342 components.append(str(milliseconds)+" millisecond"+sms if verbose else str(milliseconds)+"ms") 343 displayed += 1 344 345 if microseconds > 0 and ((not compact) or displayed < 2): 346 components.append(str(microseconds)+" microsecond"+sus if verbose else str(microseconds)+"µs") 347 displayed += 1 348 349 i = 0 350 tstr = "" 351 for c in components: 352 i += 1 353 if i == 1: 354 pass 355 elif i < len(components): 356 tstr += ", " 357 elif i == len(components): 358 tstr += " and " 359 360 tstr += c 361 362 if tstr == "": 363 return "0us" 364 else: 365 if not neg: 366 return tstr 367 else: 368 return f"-{tstr}" 369 370 def phyparams(): 371 print("Required Physical Layer MTU : "+str(Reticulum.MTU)+" bytes") 372 print("Plaintext Packet MDU : "+str(Packet.PLAIN_MDU)+" bytes") 373 print("Encrypted Packet MDU : "+str(Packet.ENCRYPTED_MDU)+" bytes") 374 print("Link Curve : "+str(Link.CURVE)) 375 print("Link Packet MDU : "+str(Link.MDU)+" bytes") 376 print("Link Public Key Size : "+str(Link.ECPUBSIZE*8)+" bits") 377 print("Link Private Key Size : "+str(Link.KEYSIZE*8)+" bits") 378 379 def panic(): 380 os._exit(255) 381 382 exit_called = False 383 def exit(code=0): 384 global exit_called 385 if not exit_called: 386 exit_called = True 387 Reticulum.exit_handler() 388 os._exit(code) 389 390 class Profiler: 391 _ran = False 392 profilers = {} 393 tags = {} 394 395 @staticmethod 396 def get_profiler(tag=None, super_tag=None): 397 if tag in Profiler.profilers: 398 return Profiler.profilers[tag] 399 else: 400 profiler = Profiler(tag, super_tag) 401 Profiler.profilers[tag] = profiler 402 return profiler 403 404 def __init__(self, tag=None, super_tag=None): 405 self.paused = False 406 self.pause_time = 0 407 self.pause_started = None 408 self.tag = tag 409 self.super_tag = super_tag 410 if self.super_tag in Profiler.profilers: 411 self.super_profiler = Profiler.profilers[self.super_tag] 412 self.pause_super = self.super_profiler.pause 413 self.resume_super = self.super_profiler.resume 414 else: 415 def noop(self=None): 416 pass 417 self.super_profiler = None 418 self.pause_super = noop 419 self.resume_super = noop 420 421 def __enter__(self): 422 self.pause_super() 423 tag = self.tag 424 super_tag = self.super_tag 425 thread_ident = threading.get_ident() 426 if not tag in Profiler.tags: 427 Profiler.tags[tag] = {"threads": {}, "super": super_tag} 428 if not thread_ident in Profiler.tags[tag]["threads"]: 429 Profiler.tags[tag]["threads"][thread_ident] = {"current_start": None, "captures": []} 430 431 Profiler.tags[tag]["threads"][thread_ident]["current_start"] = time.perf_counter() 432 self.resume_super() 433 434 def __exit__(self, exc_type, exc_value, traceback): 435 self.pause_super() 436 tag = self.tag 437 super_tag = self.super_tag 438 end = time.perf_counter() - self.pause_time 439 self.pause_time = 0 440 thread_ident = threading.get_ident() 441 if tag in Profiler.tags and thread_ident in Profiler.tags[tag]["threads"]: 442 if Profiler.tags[tag]["threads"][thread_ident]["current_start"] != None: 443 begin = Profiler.tags[tag]["threads"][thread_ident]["current_start"] 444 Profiler.tags[tag]["threads"][thread_ident]["current_start"] = None 445 Profiler.tags[tag]["threads"][thread_ident]["captures"].append(end-begin) 446 if not Profiler._ran: 447 Profiler._ran = True 448 self.resume_super() 449 450 def pause(self, pause_started=None): 451 if not self.paused: 452 self.paused = True 453 self.pause_started = pause_started or time.perf_counter() 454 self.pause_super(self.pause_started) 455 456 def resume(self): 457 if self.paused: 458 self.pause_time += time.perf_counter() - self.pause_started 459 self.paused = False 460 self.resume_super() 461 462 @staticmethod 463 def ran(): 464 return Profiler._ran 465 466 @staticmethod 467 def results(): 468 from statistics import mean, median, stdev 469 results = {} 470 471 for tag in Profiler.tags: 472 tag_captures = [] 473 tag_entry = Profiler.tags[tag] 474 475 for thread_ident in tag_entry["threads"]: 476 thread_entry = tag_entry["threads"][thread_ident] 477 thread_captures = thread_entry["captures"] 478 sample_count = len(thread_captures) 479 480 if sample_count > 1: 481 thread_results = { 482 "count": sample_count, 483 "mean": mean(thread_captures), 484 "median": median(thread_captures), 485 "stdev": stdev(thread_captures) 486 } 487 elif sample_count == 1: 488 thread_results = { 489 "count": sample_count, 490 "mean": mean(thread_captures), 491 "median": median(thread_captures), 492 "stdev": None 493 } 494 495 tag_captures.extend(thread_captures) 496 497 sample_count = len(tag_captures) 498 if sample_count > 1: 499 tag_results = { 500 "name": tag, 501 "super": tag_entry["super"], 502 "count": len(tag_captures), 503 "mean": mean(tag_captures), 504 "median": median(tag_captures), 505 "stdev": stdev(tag_captures) 506 } 507 elif sample_count == 1: 508 tag_results = { 509 "name": tag, 510 "super": tag_entry["super"], 511 "count": len(tag_captures), 512 "mean": mean(tag_captures), 513 "median": median(tag_captures), 514 "stdev": None 515 } 516 517 results[tag] = tag_results 518 519 def print_results_recursive(tag, results, level=0): 520 print_tag_results(tag, level+1) 521 522 for tag_name in results: 523 sub_tag = results[tag_name] 524 if sub_tag["super"] == tag["name"]: 525 print_results_recursive(sub_tag, results, level=level+1) 526 527 528 def print_tag_results(tag, level): 529 ind = " "*level 530 name = tag["name"]; count = tag["count"] 531 mean = tag["mean"]; median = tag["median"]; stdev = tag["stdev"] 532 print( f"{ind}{name}") 533 print( f"{ind} Samples : {count}") 534 if stdev != None: 535 print(f"{ind} Mean : {prettyshorttime(mean)}") 536 print(f"{ind} Median : {prettyshorttime(median)}") 537 print(f"{ind} St.dev. : {prettyshorttime(stdev)}") 538 print( f"{ind} Total : {prettyshorttime(mean*count)}") 539 print("") 540 541 print("\nProfiler results:\n") 542 for tag_name in results: 543 tag = results[tag_name] 544 if tag["super"] == None: 545 print_results_recursive(tag, results) 546 547 profile = Profiler.get_profiler