/ RNS / Utilities / rnstatus.py
rnstatus.py
  1  #!/usr/bin/env python3
  2  
  3  # Reticulum License
  4  #
  5  # Copyright (c) 2016-2025 Mark Qvist
  6  #
  7  # Permission is hereby granted, free of charge, to any person obtaining a copy
  8  # of this software and associated documentation files (the "Software"), to deal
  9  # in the Software without restriction, including without limitation the rights
 10  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 11  # copies of the Software, and to permit persons to whom the Software is
 12  # furnished to do so, subject to the following conditions:
 13  #
 14  # - The Software shall not be used in any kind of system which includes amongst
 15  #   its functions the ability to purposefully do harm to human beings.
 16  #
 17  # - The Software shall not be used, directly or indirectly, in the creation of
 18  #   an artificial intelligence, machine learning or language model training
 19  #   dataset, including but not limited to any use that contributes to the
 20  #   training or development of such a model or algorithm.
 21  #
 22  # - The above copyright notice and this permission notice shall be included in
 23  #   all copies or substantial portions of the Software.
 24  #
 25  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 26  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 27  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 28  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 29  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 30  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 31  # SOFTWARE.
 32  
 33  import RNS
 34  import os
 35  import sys
 36  import time
 37  import argparse
 38  import io
 39  
 40  from RNS._version import __version__
 41  
 42  def size_str(num, suffix='B'):
 43      units = ['','K','M','G','T','P','E','Z']
 44      last_unit = 'Y'
 45  
 46      if suffix == 'b':
 47          num *= 8
 48          units = ['','K','M','G','T','P','E','Z']
 49          last_unit = 'Y'
 50  
 51      for unit in units:
 52          if abs(num) < 1000.0:
 53              if unit == "":
 54                  return "%.0f %s%s" % (num, unit, suffix)
 55              else:
 56                  return "%.2f %s%s" % (num, unit, suffix)
 57          num /= 1000.0
 58  
 59      return "%.2f%s%s" % (num, last_unit, suffix)
 60  
 61  request_result = None
 62  request_concluded = False
 63  def get_remote_status(destination_hash, include_lstats, identity, no_output=False, timeout=RNS.Transport.PATH_REQUEST_TIMEOUT):
 64      global request_result, request_concluded
 65      link_count = None
 66  
 67      if not RNS.Transport.has_path(destination_hash):
 68          if not no_output:
 69              print("Path to "+RNS.prettyhexrep(destination_hash)+" requested", end=" ")
 70              sys.stdout.flush()
 71          RNS.Transport.request_path(destination_hash)
 72          pr_time = time.time()
 73          while not RNS.Transport.has_path(destination_hash):
 74              time.sleep(0.1)
 75              if time.time() - pr_time > timeout:
 76                  if not no_output:
 77                      print("\r                                                          \r", end="")
 78                      print("Path request timed out")
 79                      exit(12)
 80  
 81      remote_identity = RNS.Identity.recall(destination_hash)
 82  
 83      def remote_link_closed(link):
 84          if link.teardown_reason == RNS.Link.TIMEOUT:
 85              if not no_output:
 86                  print("\r                                                          \r", end="")
 87                  print("The link timed out, exiting now")
 88          elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED:
 89              if not no_output:
 90                  print("\r                                                          \r", end="")
 91                  print("The link was closed by the server, exiting now")
 92          else:
 93              if not no_output:
 94                  print("\r                                                          \r", end="")
 95                  print("Link closed unexpectedly, exiting now")
 96          exit(10)
 97  
 98      def request_failed(request_receipt):
 99          global request_result, request_concluded
100          if not no_output:
101              print("\r                                                          \r", end="")
102              print("The remote status request failed. Likely authentication failure.")
103          request_concluded = True
104  
105      def got_response(request_receipt):
106          global request_result, request_concluded
107          response = request_receipt.response
108          if isinstance(response, list):
109              status = response[0]
110              if len(response) > 1:
111                  link_count = response[1]
112              else:
113                  link_count = None
114  
115              request_result = (status, link_count)
116  
117          request_concluded = True
118  
119      def remote_link_established(link):
120          if not no_output:
121              print("\r                                                          \r", end="")
122              print("Sending request...", end=" ")
123              sys.stdout.flush()
124          link.identify(identity)
125          link.request("/status", data = [include_lstats], response_callback = got_response, failed_callback = request_failed)
126  
127      if not no_output:
128          print("\r                                                          \r", end="")
129          print("Establishing link with remote transport instance...", end=" ")
130          sys.stdout.flush()
131  
132      remote_destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "remote", "management")
133      link = RNS.Link(remote_destination)
134      link.set_link_established_callback(remote_link_established)
135      link.set_link_closed_callback(remote_link_closed)
136  
137      while not request_concluded:
138          time.sleep(0.1)
139  
140      if request_result != None:
141          print("\r                                                          \r", end="")
142  
143      return request_result
144  
145  def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=False, astats=False, lstats=False, sorting=None, sort_reverse=False,
146                    remote=None, management_identity=None, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT, must_exit=True, rns_instance=None,
147                    traffic_totals=False, discovered_interfaces=False, config_entries=False):
148    
149      if remote: require_shared = False
150      else: require_shared = True
151  
152      try:
153          if rns_instance:
154              reticulum = rns_instance
155              must_exit = False
156          else:
157              reticulum = RNS.Reticulum(configdir=configdir, loglevel=3+verbosity, require_shared_instance=require_shared)
158  
159      except Exception as e:
160          print("No shared RNS instance available to get status from")
161          if must_exit: exit(1)
162          else: return
163  
164      link_count = None
165      stats = None
166  
167      details = False
168      if config_entries:
169          discovered_interfaces = True
170          details = True
171  
172      if discovered_interfaces:
173          if_discovery = RNS.Discovery.InterfaceDiscovery(discover_interfaces=False)
174          ifs = if_discovery.list_discovered_interfaces()
175          print("")
176  
177          if json:
178              import json
179              for i in ifs:
180                  for e in i:
181                      if isinstance(i[e], bytes): i[e] = RNS.hexrep(i[e], delimit=False)
182            
183              print(json.dumps(ifs))
184  
185          else:
186              filtered_ifs = []
187              for i in ifs:
188                  name = i["name"]
189                  if not name_filter or name_filter.lower() in name.lower(): filtered_ifs.append(i)
190            
191              if details:
192                  for idx, i in enumerate(filtered_ifs):
193                      try:
194                          name = i["name"]
195                          if_type = i["type"]
196                          status = i["status"]
197  
198                          if status == "available": status_display = "Available"
199                          elif status == "unknown": status_display = "Unknown"
200                          elif status == "stale":   status_display = "Stale"
201                          else:                     status_display = status
202  
203                          now  = time.time()
204                          dago = now-i["discovered"]
205                          hago = now-i["last_heard"]
206                          discovered_display = f"{RNS.prettytime(dago, compact=True)} ago"
207                          last_heard_display = f"{RNS.prettytime(hago, compact=True)} ago"
208                          transport_str = "Enabled" if i["transport"] else "Disabled"
209  
210                          if i["latitude"] is not None and i["longitude"] is not None:
211                              lat = round(i["latitude"], 4)
212                              lon = round(i["longitude"], 4)
213                              if i["height"] != None: height = ", "+str(i["height"])+"m h"
214                              else: height = ""
215                              location = f"{lat}, {lon}{height}"
216                          else: location = "Unknown"
217  
218                          transport_id = None
219                          network = None
220                          if "transport_id" in i: transport_id = i["transport_id"]
221                          if "transport_id" in i and "network_id" in i and i["transport_id"] != i["network_id"]:
222                              network = i["network_id"]
223  
224                          if idx > 0:      print("\n"+"="*32+"\n")
225                          if network:      print(f"Network   ID : {network}")
226                          if transport_id: print(f"Transport ID : {transport_id}")
227  
228                          print(f"Name         : {name}")
229                          print(f"Type         : {if_type}")
230                          print(f"Status       : {status_display}")
231                          print(f"Transport    : {transport_str}")
232                          print(f"Distance     : {i['hops']} hop{'' if i['hops'] == 1 else 's'}")
233                          print(f"Discovered   : {discovered_display}")
234                          print(f"Last Heard   : {last_heard_display}")
235                          print(f"Location     : {location}")
236  
237                          if "frequency" in i:    print(f"Frequency    : {i['frequency']:,} Hz")
238                          if "bandwidth" in i:    print(f"Bandwidth    : {i['bandwidth']:,} Hz")
239                          if "sf" in i:           print(f"Sprd. Factor : {i['sf']}")
240                          if "cr" in i:           print(f"Coding Rate  : {i['cr']}")
241                          if "modulation" in i:   print(f"Modulation   : {i['modulation']}")
242                          if "reachable_on" in i: print(f"Address      : {i['reachable_on']}")
243                          if "reachable_on" in i: print(f"Port         : {i['port']}")
244  
245                          print(f"Stamp Value  : {i['value']}")
246  
247                          print(f"\nConfiguration Entry:")
248                          config_lines = i["config_entry"].split('\n')
249                          for line in config_lines: print(f"  {line}")
250  
251                      except Exception as e:
252                          pass
253                
254              else:
255                  print(f"{'Name':<25} {'Type':<12} {'Status':<12} {'Last Heard':<12} {'Value':<8} {'Location':<15}")
256                  print("-" * 89)
257                  
258                  for i in filtered_ifs:
259                      try:
260                          name = i["name"][:24] + "…" if len(i["name"]) > 24 else i["name"]
261                          
262                          if_type = i["type"].replace("Interface", "")
263                          
264                          status = i["status"]
265                          if status == "available": status_display = "✓ Available"
266                          elif status == "unknown": status_display = "? Unknown"
267                          elif status == "stale":   status_display = "× Stale"
268                          else:                     status_display = status
269                          
270                          now = time.time()
271                          last_heard = i["last_heard"]
272                          diff = now - last_heard
273                          
274                          if diff < 60: last_heard_display = "Just now"
275                          elif diff < 3600:
276                              mins = int(diff / 60)
277                              last_heard_display = f"{mins}m ago"
278                          elif diff < 86400:
279                              hours = int(diff / 3600)
280                              last_heard_display = f"{hours}h ago"
281                          else:
282                              days = int(diff / 86400)
283                              last_heard_display = f"{days}d ago"
284                          
285                          value = str(i["value"])
286                          
287                          if i["latitude"] is not None and i["longitude"] is not None:
288                              lat = round(i["latitude"], 4)
289                              lon = round(i["longitude"], 4)
290                              location = f"{lat}, {lon}"
291                          else: location = "N/A"
292                          
293                          print(f"{name:<25} {if_type:<12} {status_display:<12} {last_heard_display:<12} {value:<8} {location:<15}")
294  
295                      except Exception as e:
296                          pass
297  
298          if must_exit: exit(0)
299          else: return
300  
301      if remote:
302          try:
303              if management_identity is None:
304                  raise ValueError("Remote management requires an identity file. Use -i to specify the path to a management identity.")
305  
306              dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
307              if len(remote) != dest_len:
308                  raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
309              try:
310                  identity_hash = bytes.fromhex(remote)
311                  destination_hash = RNS.Destination.hash_from_name_and_identity("rnstransport.remote.management", identity_hash)
312              except Exception as e:
313                  raise ValueError("Invalid destination entered. Check your input.")
314  
315              identity = RNS.Identity.from_file(os.path.expanduser(management_identity))
316              if identity == None:
317                  raise ValueError("Could not load management identity from "+str(management_identity))
318  
319              try:
320                  remote_status = get_remote_status(destination_hash, lstats, identity, no_output=json, timeout=remote_timeout)
321                  if remote_status != None:
322                      stats, link_count = remote_status
323              except Exception as e:
324                  raise e
325                    
326          except Exception as e:
327              print(str(e))
328              if must_exit: exit(20)
329              else: return
330  
331      else:
332          if lstats:
333              try: link_count = reticulum.get_link_count()
334              except Exception as e: pass
335  
336          try: stats = reticulum.get_interface_stats()
337          except Exception as e: pass
338  
339      if stats != None:
340          if json:
341              import json
342              for s in stats:
343                  if isinstance(stats[s], bytes):
344                      stats[s] = RNS.hexrep(stats[s], delimit=False)
345  
346                  if isinstance(stats[s], dict) or isinstance(stats[s], list):
347                      for i in stats[s]:
348                          if isinstance(i, dict):
349                              for k in i:
350                                  if isinstance(i[k], bytes):
351                                      i[k] = RNS.hexrep(i[k], delimit=False)
352  
353              print(json.dumps(stats))
354              if must_exit: exit()
355              else: return
356  
357          interfaces = stats["interfaces"]
358          if sorting != None and isinstance(sorting, str):
359              sorting = sorting.lower()
360              if sorting == "rate" or sorting == "bitrate":
361                  interfaces.sort(key=lambda i: i["bitrate"], reverse=not sort_reverse)
362              if sorting == "rx":
363                  interfaces.sort(key=lambda i: i["rxb"], reverse=not sort_reverse)
364              if sorting == "tx":
365                  interfaces.sort(key=lambda i: i["txb"], reverse=not sort_reverse)
366              if sorting == "rxs":
367                  interfaces.sort(key=lambda i: i["rxs"], reverse=not sort_reverse)
368              if sorting == "txs":
369                  interfaces.sort(key=lambda i: i["txs"], reverse=not sort_reverse)
370              if sorting == "traffic":
371                  interfaces.sort(key=lambda i: i["rxb"]+i["txb"], reverse=not sort_reverse)
372              if sorting == "announces" or sorting == "announce":
373                  interfaces.sort(key=lambda i: i["incoming_announce_frequency"]+i["outgoing_announce_frequency"], reverse=not sort_reverse)
374              if sorting == "arx":
375                  interfaces.sort(key=lambda i: i["incoming_announce_frequency"], reverse=not sort_reverse)
376              if sorting == "atx":
377                  interfaces.sort(key=lambda i: i["outgoing_announce_frequency"], reverse=not sort_reverse)
378              if sorting == "held":
379                  interfaces.sort(key=lambda i: i["held_announces"], reverse=not sort_reverse)
380  
381            
382          for ifstat in interfaces:
383              name = ifstat["name"]
384  
385              if dispall or not (
386                  name.startswith("LocalInterface[") or
387                  name.startswith("TCPInterface[Client") or
388                  name.startswith("BackboneInterface[Client on") or
389                  name.startswith("AutoInterfacePeer[") or
390                  name.startswith("WeaveInterfacePeer[") or
391                  name.startswith("I2PInterfacePeer[Connected peer") or
392                  (name.startswith("I2PInterface[") and ("i2p_connectable" in ifstat and ifstat["i2p_connectable"] == False))
393                  ):
394  
395                  if not (name.startswith("I2PInterface[") and ("i2p_connectable" in ifstat and ifstat["i2p_connectable"] == False)):
396                      if name_filter == None or name_filter.lower() in name.lower():
397                          print("")
398  
399                          if ifstat["status"]: ss = "Up"
400                          else: ss = "Down"
401  
402                          if ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT: modestr = "Access Point"
403                          elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_POINT_TO_POINT: modestr = "Point-to-Point"
404                          elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_ROAMING: modestr = "Roaming"
405                          elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_BOUNDARY: modestr = "Boundary"
406                          elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_GATEWAY: modestr = "Gateway"
407                          else: modestr = "Full"
408  
409  
410                          if ifstat["clients"] != None:
411                              clients = ifstat["clients"]
412                              if name.startswith("Shared Instance["):
413                                  cnum = max(clients-1,0)
414                                  if cnum == 1:
415                                      spec_str = " program"
416                                  else:
417                                      spec_str = " programs"
418  
419                                  clients_string = "Serving   : "+str(cnum)+spec_str
420                              elif name.startswith("I2PInterface["):
421                                  if "i2p_connectable" in ifstat and ifstat["i2p_connectable"] == True:
422                                      cnum = clients
423                                      if cnum == 1:
424                                          spec_str = " connected I2P endpoint"
425                                      else:
426                                          spec_str = " connected I2P endpoints"
427  
428                                      clients_string = "Peers     : "+str(cnum)+spec_str
429                                  else:
430                                      clients_string = ""
431                              else:
432                                  clients_string = "Clients   : "+str(clients)
433  
434                          else:
435                              clients = None
436  
437                          print(" {n}".format(n=ifstat["name"]))
438  
439                          if "autoconnect_source" in ifstat and ifstat["autoconnect_source"] != None:
440                              print("    Source    : Auto-connect via <{ns}>".format(ns=ifstat["autoconnect_source"]))
441  
442                          if "ifac_netname" in ifstat and ifstat["ifac_netname"] != None:
443                              print("    Network   : {nn}".format(nn=ifstat["ifac_netname"]))
444  
445                          print("    Status    : {ss}".format(ss=ss))
446  
447                          if clients != None and clients_string != "":
448                              print("    "+clients_string)
449  
450                          if not (name.startswith("Shared Instance[") or name.startswith("TCPInterface[Client") or name.startswith("LocalInterface[")):
451                              print("    Mode      : {mode}".format(mode=modestr))
452  
453                          if "bitrate" in ifstat and ifstat["bitrate"] != None:
454                              print("    Rate      : {ss}".format(ss=speed_str(ifstat["bitrate"])))
455  
456                          if "noise_floor" in ifstat:
457                              if not "interference" in ifstat: nstr = ""
458                              else:
459                                  nf = ifstat["interference"]
460                                  lstr = ", no interference"
461                                  if "interference_last_ts" in ifstat and "interference_last_dbm" in ifstat:
462                                      lago = time.time()-ifstat["interference_last_ts"]
463                                      ldbm = ifstat["interference_last_dbm"]
464                                      lstr = f"\n    Intrfrnc. : {ldbm} dBm {RNS.prettytime(lago, compact=True)} ago"
465  
466  
467                                  nstr = f"\n    Intrfrnc. : {nf} dBm" if nf else lstr
468  
469                              if ifstat["noise_floor"] != None: print("    Noise Fl. : {nfl} dBm{ntr}".format(nfl=str(ifstat["noise_floor"]), ntr=nstr))
470                              else: print("    Noise Fl. : Unknown")
471  
472                          if "cpu_load" in ifstat:
473                              if ifstat["cpu_load"] != None: print("    CPU load  : {v} %".format(v=str(ifstat["cpu_load"])))
474                              else:                          print("    CPU load  : Unknown")
475  
476                          if "cpu_temp" in ifstat:
477                              if ifstat["cpu_temp"] != None: print("    CPU temp  : {v}°C".format(v=str(ifstat["cpu_temp"])))
478                              else:                          print("    CPU load  : Unknown")
479  
480                          if "mem_load" in ifstat:
481                              if ifstat["cpu_load"] != None: print("    Mem usage : {v} %".format(v=str(ifstat["mem_load"])))
482                              else:                          print("    Mem usage : Unknown")
483  
484                          if "battery_percent" in ifstat and ifstat["battery_percent"] != None:
485                              try:
486                                  bpi = int(ifstat["battery_percent"])
487                                  bss = ifstat["battery_state"]
488                                  print(f"    Battery   : {bpi}% ({bss})")
489                              except:
490                                  pass
491  
492                          if "airtime_short" in ifstat and "airtime_long" in ifstat:
493                              print("    Airtime   : {ats}% (15s), {atl}% (1h)".format(ats=str(ifstat["airtime_short"]),atl=str(ifstat["airtime_long"])))
494                        
495                          if "channel_load_short" in ifstat and "channel_load_long" in ifstat:
496                              print("    Ch. Load  : {ats}% (15s), {atl}% (1h)".format(ats=str(ifstat["channel_load_short"]),atl=str(ifstat["channel_load_long"])))
497  
498                          if "switch_id" in ifstat:
499                              if ifstat["switch_id"] != None: print("    Switch ID : {v}".format(v=str(ifstat["switch_id"])))
500                              else:                           print("    Switch ID : Unknown")
501  
502                          if "endpoint_id" in ifstat:
503                              if ifstat["endpoint_id"] != None: print("    Endpoint  : {v}".format(v=str(ifstat["endpoint_id"])))
504                              else:                             print("    Endpoint  : Unknown")
505  
506                          if "via_switch_id" in ifstat:
507                              if ifstat["via_switch_id"] != None: print("    Via       : {v}".format(v=str(ifstat["via_switch_id"])))
508                              else:                               print("    Via       : Unknown")
509  
510                          if "peers" in ifstat and ifstat["peers"] != None:
511                              print("    Peers     : {np} reachable".format(np=ifstat["peers"]))
512  
513                          if "tunnelstate" in ifstat and ifstat["tunnelstate"] != None:
514                              print("    I2P       : {ts}".format(ts=ifstat["tunnelstate"]))
515  
516                          if "ifac_signature" in ifstat and ifstat["ifac_signature"] != None:
517                              sigstr = "<…"+RNS.hexrep(ifstat["ifac_signature"][-5:], delimit=False)+">"
518                              print("    Access    : {nb}-bit IFAC by {sig}".format(nb=ifstat["ifac_size"]*8, sig=sigstr))
519                        
520                          if "i2p_b32" in ifstat and ifstat["i2p_b32"] != None:
521                              print("    I2P B32   : {ep}".format(ep=str(ifstat["i2p_b32"])))
522  
523                          if astats and "announce_queue" in ifstat and ifstat["announce_queue"] != None and ifstat["announce_queue"] > 0:
524                              aqn = ifstat["announce_queue"]
525                              if aqn == 1:
526                                  print("    Queued    : {np} announce".format(np=aqn))
527                              else:
528                                  print("    Queued    : {np} announces".format(np=aqn))
529                        
530                          if astats and "held_announces" in ifstat and ifstat["held_announces"] != None and ifstat["held_announces"] > 0:
531                              aqn = ifstat["held_announces"]
532                              if aqn == 1:
533                                  print("    Held      : {np} announce".format(np=aqn))
534                              else:
535                                  print("    Held      : {np} announces".format(np=aqn))
536                        
537                          if astats and "incoming_announce_frequency" in ifstat and ifstat["incoming_announce_frequency"] != None:
538                              print("    Announces : {iaf}↑".format(iaf=RNS.prettyfrequency(ifstat["outgoing_announce_frequency"])))
539                              print("                {iaf}↓".format(iaf=RNS.prettyfrequency(ifstat["incoming_announce_frequency"])))
540  
541                          rxb_str = "↓"+RNS.prettysize(ifstat["rxb"])
542                          txb_str = "↑"+RNS.prettysize(ifstat["txb"])
543                          strdiff = len(rxb_str)-len(txb_str)
544                          if strdiff > 0:
545                              txb_str += " "*strdiff
546                          elif strdiff < 0:
547                              rxb_str += " "*-strdiff
548  
549                          rxstat = rxb_str
550                          txstat = txb_str
551                          if "rxs" in ifstat and "txs" in ifstat:
552                              rxstat += "  "+RNS.prettyspeed(ifstat["rxs"])
553                              txstat += "  "+RNS.prettyspeed(ifstat["txs"])
554                        
555                          print(f"    Traffic   : {txstat}\n                {rxstat}")
556  
557          lstr = ""
558          if link_count != None and lstats:
559              ms = "y" if link_count == 1 else "ies"
560              if "transport_id" in stats and stats["transport_id"] != None:
561                  lstr = f", {link_count} entr{ms} in link table"
562              else:
563                  lstr = f" {link_count} entr{ms} in link table"
564  
565          if traffic_totals:
566              rxb_str = "↓"+RNS.prettysize(stats["rxb"])
567              txb_str = "↑"+RNS.prettysize(stats["txb"])
568              strdiff = len(rxb_str)-len(txb_str)
569              if strdiff > 0:
570                  txb_str += " "*strdiff
571              elif strdiff < 0:
572                  rxb_str += " "*-strdiff
573  
574              rxstat  = rxb_str+"  "+RNS.prettyspeed(stats["rxs"])
575              txstat  = txb_str+"  "+RNS.prettyspeed(stats["txs"])
576              print(f"\n Totals       : {txstat}\n                {rxstat}")
577  
578          if "transport_id" in stats and stats["transport_id"] != None:
579              print("\n Transport Instance "+RNS.prettyhexrep(stats["transport_id"])+" running")
580              if "network_id" in stats and stats["network_id"] != None:
581                  print(" Network Identity   "+RNS.prettyhexrep(stats["network_id"]))
582              if "probe_responder" in stats and stats["probe_responder"] != None:
583                  print(" Probe responder at "+RNS.prettyhexrep(stats["probe_responder"])+ " active")
584              if "transport_uptime" in stats and stats["transport_uptime"] != None:
585                  print(" Uptime is "+RNS.prettytime(stats["transport_uptime"])+lstr)
586          else:
587              if lstr != "":
588                  print(f"\n{lstr}")
589  
590          print("")
591                
592      else:
593          if not remote:
594              print("Could not get RNS status")
595          else:
596              print("Could not get RNS status from remote transport instance "+RNS.prettyhexrep(identity_hash))
597          if must_exit:
598              exit(2)
599          else:
600              return
601  
602  def main(must_exit=True, rns_instance=None):
603      try:
604          parser = argparse.ArgumentParser(description="Reticulum Network Stack Status")
605          parser.add_argument("--config", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
606          parser.add_argument("--version", action="version", version="rnstatus {version}".format(version=__version__))
607  
608          parser.add_argument("-a", "--all", action="store_true", help="show all interfaces", default=False)
609          parser.add_argument("-A", "--announce-stats", action="store_true", help="show announce stats", default=False)
610          parser.add_argument("-l", "--link-stats", action="store_true", help="show link stats", default=False)
611          parser.add_argument("-t", "--totals", action="store_true", help="display traffic totals", default=False)
612          parser.add_argument("-s", "--sort", action="store", help="sort interfaces by [rate, traffic, rx, tx, rxs, txs, announces, arx, atx, held]", default=None, type=str)
613          parser.add_argument("-r", "--reverse", action="store_true", help="reverse sorting", default=False)
614          parser.add_argument("-j", "--json", action="store_true", help="output in JSON format", default=False)
615          parser.add_argument("-R", action="store", metavar="hash", help="transport identity hash of remote instance to get status from", default=None, type=str)
616          parser.add_argument("-i", action="store", metavar="path", help="path to identity used for remote management", default=None, type=str)
617          parser.add_argument("-w", action="store", metavar="seconds", type=float, help="timeout before giving up on remote queries", default=RNS.Transport.PATH_REQUEST_TIMEOUT)
618          parser.add_argument("-d", "--discovered", action="store_true", help="list discovered interfaces", default=False)
619          parser.add_argument("-D",                 action="store_true", help="show details and config entries for discovered interfaces", default=False)
620          parser.add_argument("-m", "--monitor", action="store_true", help="continuously monitor status", default=False)
621          parser.add_argument("-I", "--monitor-interval", action="store", metavar="seconds", type=float, help="refresh interval for monitor mode (default: 1)", default=1.0)
622          parser.add_argument('-v', '--verbose', action='count', default=0)
623          parser.add_argument("filter", nargs="?", default=None, help="only display interfaces with names including filter", type=str)
624        
625          args = parser.parse_args()
626  
627          if args.config: configarg = args.config
628          else:           configarg = None
629  
630          if args.monitor:
631              if args.R: require_shared = False
632              else:      require_shared = True
633            
634              try: reticulum = RNS.Reticulum(configdir=configarg, loglevel=3+args.verbose, require_shared_instance=require_shared)
635              except Exception as e:
636                  print("No shared RNS instance available to get status from")
637                  exit(1)
638  
639              while True:
640                  buffer = io.StringIO()
641                  old_stdout = sys.stdout
642                  sys.stdout = buffer
643                
644                  try:
645                      program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
646                                    astats=args.announce_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse, remote=args.R,
647                                    management_identity=args.i, remote_timeout=args.w, must_exit=False, rns_instance=reticulum, traffic_totals=args.totals,
648                                    discovered_interfaces=args.discovered, config_entries=args.D)
649                
650                  finally:
651                      sys.stdout = old_stdout
652                
653                  output = buffer.getvalue()
654                  print("\033[H\033[2J", end="")
655                  print(output, end="", flush=True)
656                
657                  time.sleep(args.monitor_interval)
658  
659          else:
660              program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
661                            astats=args.announce_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse, remote=args.R,
662                            management_identity=args.i, remote_timeout=args.w, must_exit=must_exit, rns_instance=rns_instance, traffic_totals=args.totals,
663                            discovered_interfaces=args.discovered, config_entries=args.D)
664  
665      except KeyboardInterrupt:
666          print("")
667          if must_exit: exit()
668          else: return
669  
670  def speed_str(num, suffix='bps'):
671      units = ['','k','M','G','T','P','E','Z']
672      last_unit = 'Y'
673  
674      if suffix == 'Bps':
675          num /= 8
676          units = ['','K','M','G','T','P','E','Z']
677          last_unit = 'Y'
678  
679      for unit in units:
680          if abs(num) < 1000.0:
681              return "%3.2f %s%s" % (num, unit, suffix)
682          num /= 1000.0
683  
684      return "%.2f %s%s" % (num, last_unit, suffix)
685  
686  if __name__ == "__main__":
687      main()