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