/ RNS / __init__.py
__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