meshcore-cli 1.2.1__tar.gz → 1.2.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcore-cli
3
- Version: 1.2.1
3
+ Version: 1.2.11
4
4
  Summary: Command line interface to meshcore companion radios
5
5
  Project-URL: Homepage, https://github.com/fdlamotte/meshcore-cli
6
6
  Project-URL: Issues, https://github.com/fdlamotte/meshcore-cli/issues
@@ -10,8 +10,9 @@ License-File: LICENSE
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.10
13
- Requires-Dist: meshcore>=2.1.19
13
+ Requires-Dist: meshcore>=2.1.23
14
14
  Requires-Dist: prompt-toolkit>=3.0.50
15
+ Requires-Dist: pycryptodome
15
16
  Requires-Dist: requests>=2.28.0
16
17
  Description-Content-Type: text/markdown
17
18
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore-cli"
7
- version = "1.2.1"
7
+ version = "1.2.11"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  ]
@@ -17,7 +17,7 @@ classifiers = [
17
17
  ]
18
18
  license = "MIT"
19
19
  license-files = ["LICEN[CS]E*"]
20
- dependencies = [ "meshcore >= 2.1.19", "prompt_toolkit >= 3.0.50", "requests >= 2.28.0" ]
20
+ dependencies = [ "meshcore >= 2.1.23", "prompt_toolkit >= 3.0.50", "requests >= 2.28.0", "pycryptodome" ]
21
21
 
22
22
  [project.urls]
23
23
  Homepage = "https://github.com/fdlamotte/meshcore-cli"
@@ -4,7 +4,7 @@
4
4
  """
5
5
 
6
6
  import asyncio
7
- import os, sys
7
+ import os, sys, io, platform
8
8
  import time, datetime
9
9
  import getopt, json, shlex, re
10
10
  import logging
@@ -24,13 +24,15 @@ from prompt_toolkit.key_binding import KeyBindings
24
24
  from prompt_toolkit.shortcuts import radiolist_dialog
25
25
  from prompt_toolkit.completion.word_completer import WordCompleter
26
26
  from prompt_toolkit.document import Document
27
+ from Crypto.Cipher import AES
28
+ from Crypto.Hash import HMAC, SHA256
27
29
 
28
30
  import re
29
31
 
30
32
  from meshcore import MeshCore, EventType, logger
31
33
 
32
34
  # Version
33
- VERSION = "v1.2.1"
35
+ VERSION = "v1.2.11"
34
36
 
35
37
  # default ble address is stored in a config file
36
38
  MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/"
@@ -52,6 +54,7 @@ ANSI_INVERT = "\033[7m"
52
54
  ANSI_NORMAL = "\033[27m"
53
55
  ANSI_GREEN = "\033[0;32m"
54
56
  ANSI_BGREEN = "\033[1;32m"
57
+ ANSI_DGREEN="\033[0;38;5;22m"
55
58
  ANSI_BLUE = "\033[0;34m"
56
59
  ANSI_BBLUE = "\033[1;34m"
57
60
  ANSI_RED = "\033[0;31m"
@@ -72,6 +75,15 @@ ANSI_BORANGE="\033[1;38;5;214m"
72
75
  ANSI_YELLOW = "\033[0;33m"
73
76
  ANSI_BYELLOW = "\033[1;33m"
74
77
 
78
+ #Unicode chars
79
+ # some possible symbols for prompts 🭬🬛🬗🭬🬛🬃🬗🭬🬛🬃🬗🬏🭀🭋🭨🮋
80
+ ARROW_TAIL = "🭨"
81
+ ARROW_HEAD = "🭬"
82
+
83
+ if platform.system() == 'Windows' or platform.system() == 'Darwin':
84
+ ARROW_TAIL = ""
85
+ ARROW_HEAD = " "
86
+
75
87
  def escape_ansi(line):
76
88
  ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
77
89
  return ansi_escape.sub('', line)
@@ -193,6 +205,66 @@ process_event_message.print_snr=False
193
205
  process_event_message.color=True
194
206
  process_event_message.last_node=None
195
207
 
208
+ async def handle_log_rx(event):
209
+ mc = handle_log_rx.mc
210
+ if handle_log_rx.json_log_rx: # json mode ... raw dump
211
+ msg = json.dumps(event.payload)
212
+ if handle_message.above:
213
+ print_above(msg)
214
+ else :
215
+ print(msg)
216
+ return
217
+
218
+ pkt = bytes().fromhex(event.payload["payload"])
219
+ pbuf = io.BytesIO(pkt)
220
+ header = pbuf.read(1)[0]
221
+
222
+ if header & ~1 == 0x14: # flood msg / channel
223
+ if handle_log_rx.channel_echoes:
224
+ if header & 1 == 0: # has transport code
225
+ pbuf.read(4) # discard transport code
226
+ path_len = pbuf.read(1)[0]
227
+ path = pbuf.read(path_len).hex()
228
+ chan_hash = pbuf.read(1).hex()
229
+ cipher_mac = pbuf.read(2)
230
+ msg = pbuf.read() # until the end of buffer
231
+
232
+ channel = None
233
+ for c in await get_channels(mc):
234
+ if c["channel_hash"] == chan_hash : # validate against MAC
235
+ h = HMAC.new(bytes.fromhex(c["channel_secret"]), digestmod=SHA256)
236
+ h.update(msg)
237
+ if h.digest()[0:2] == cipher_mac:
238
+ channel = c
239
+ break
240
+
241
+ chan_name = ""
242
+
243
+ if channel is None :
244
+ if handle_log_rx.echo_unk_chans:
245
+ chan_name = chan_hash
246
+ message = msg.hex()
247
+ else:
248
+ chan_name = channel["channel_name"]
249
+ aes_key = bytes.fromhex(channel["channel_secret"])
250
+ cipher = AES.new(aes_key, AES.MODE_ECB)
251
+ message = cipher.decrypt(msg)[5:].decode("utf-8").strip("\x00")
252
+
253
+ if chan_name != "" :
254
+ width = os.get_terminal_size().columns
255
+ cars = width - 13 - 2 * path_len - len(chan_name) - 1
256
+ dispmsg = message[0:cars]
257
+ txt = f"{ANSI_LIGHT_GRAY}{chan_name} {ANSI_DGREEN}{dispmsg+(cars-len(dispmsg))*' '} {ANSI_YELLOW}[{path}]{ANSI_LIGHT_GRAY}{event.payload['snr']:6,.2f}{event.payload['rssi']:4}{ANSI_END}"
258
+ if handle_message.above:
259
+ print_above(txt)
260
+ else:
261
+ print(txt)
262
+
263
+ handle_log_rx.json_log_rx = False
264
+ handle_log_rx.channel_echoes = False
265
+ handle_log_rx.mc = None
266
+ handle_log_rx.echo_unk_chans=False
267
+
196
268
  async def handle_advert(event):
197
269
  if not handle_advert.print_adverts:
198
270
  return
@@ -264,7 +336,7 @@ async def log_message(mc, msg):
264
336
  if msg["type"] == "PRIV" :
265
337
  ct = mc.get_contact_by_key_prefix(msg['pubkey_prefix'])
266
338
  if ct is None:
267
- msg["name"] = data["pubkey_prefix"]
339
+ msg["name"] = msg["pubkey_prefix"]
268
340
  else:
269
341
  msg["name"] = ct["adv_name"]
270
342
  elif msg["type"] == "CHAN" :
@@ -308,7 +380,7 @@ async def subscribe_to_msgs(mc, json_output=False, above=False):
308
380
  class MyNestedCompleter(NestedCompleter):
309
381
  def get_completions( self, document, complete_event):
310
382
  txt = document.text_before_cursor.lstrip()
311
- if not " " in txt:
383
+ if not " " in txt:
312
384
  if txt != "" and txt[0] == "/" and txt.count("/") == 1:
313
385
  opts = []
314
386
  for k in self.options.keys():
@@ -320,7 +392,7 @@ class MyNestedCompleter(NestedCompleter):
320
392
  opts = self.options.keys()
321
393
  completer = WordCompleter(
322
394
  opts, ignore_case=self.ignore_case,
323
- pattern=re.compile(r"([a-zA-Z0-9_\\/]+|[^a-zA-Z0-9_\s]+)"))
395
+ pattern=re.compile(r"([a-zA-Z0-9_\\/\#]+|[^a-zA-Z0-9_\s\#]+)"))
324
396
  yield from completer.get_completions(document, complete_event)
325
397
  else: # normal behavior for remainder
326
398
  yield from super().get_completions(document, complete_event)
@@ -341,6 +413,10 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
341
413
  for c in it :
342
414
  contact_list[c[1]['adv_name']] = None
343
415
 
416
+ pit = iter(pending.items())
417
+ for c in pit :
418
+ pending_list[c[1]['adv_name']] = None
419
+
344
420
  pit = iter(pending.items())
345
421
  for c in pit :
346
422
  pending_list[c[1]['public_key']] = None
@@ -383,6 +459,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
383
459
  "share_contact" : contact_list,
384
460
  "path": contact_list,
385
461
  "disc_path" : contact_list,
462
+ "node_discover": {"all":None, "sens":None, "rep":None, "comp":None, "room":None, "cli":None},
386
463
  "trace" : None,
387
464
  "reset_path" : contact_list,
388
465
  "change_path" : contact_list,
@@ -403,6 +480,9 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
403
480
  "set_channel": None,
404
481
  "get_channels": None,
405
482
  "remove_channel": None,
483
+ "apply_to": None,
484
+ "at": None,
485
+ "scope": None,
406
486
  "set" : {
407
487
  "name" : None,
408
488
  "pin" : None,
@@ -417,6 +497,9 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
417
497
  "color" : {"on":None, "off":None},
418
498
  "print_name" : {"on":None, "off":None},
419
499
  "print_adverts" : {"on":None, "off":None},
500
+ "json_log_rx" : {"on":None, "off":None},
501
+ "channel_echoes" : {"on":None, "off":None},
502
+ "echo_unk_chans" : {"on":None, "off":None},
420
503
  "print_new_contacts" : {"on": None, "off":None},
421
504
  "print_path_updates" : {"on":None,"off":None},
422
505
  "classic_prompt" : {"on" : None, "off":None},
@@ -444,6 +527,9 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
444
527
  "color":None,
445
528
  "print_name":None,
446
529
  "print_adverts":None,
530
+ "json_log_rx":None,
531
+ "channel_echoes":None,
532
+ "echo_unk_chans":None,
447
533
  "print_path_updates":None,
448
534
  "print_new_contacts":None,
449
535
  "classic_prompt":None,
@@ -463,6 +549,8 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
463
549
 
464
550
  contact_completion_list = {
465
551
  "contact_info": None,
552
+ "contact_name": None,
553
+ "contact_lastmod": None,
466
554
  "export_contact" : None,
467
555
  "share_contact" : None,
468
556
  "upload_contact" : None,
@@ -501,6 +589,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
501
589
  "neighbors" : None,
502
590
  "req_acl":None,
503
591
  "setperm":contact_list,
592
+ "region" : {"get":None, "allowf": None, "denyf": None, "put": None, "remove": None, "save": None, "home": None},
504
593
  "gps" : {"on":None,"off":None,"sync":None,"setloc":None,
505
594
  "advert" : {"none": None, "share": None, "prefs": None},
506
595
  },
@@ -584,7 +673,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
584
673
  slash_root_completion_list = {}
585
674
  for k,v in root_completion_list.items():
586
675
  slash_root_completion_list["/"+k]=v
587
-
676
+
588
677
  completion_list.update(slash_root_completion_list)
589
678
 
590
679
  slash_contacts_completion_list = {}
@@ -604,6 +693,14 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
604
693
 
605
694
  completion_list.update(slash_contacts_completion_list)
606
695
 
696
+ slash_chan_completion_list = {}
697
+ if not channels is None:
698
+ for c in channels :
699
+ if c["channel_name"] != "":
700
+ slash_chan_completion_list["/" + c["channel_name"]] = None
701
+
702
+ completion_list.update(slash_chan_completion_list)
703
+
607
704
  completion_list.update({
608
705
  "script" : None,
609
706
  "quit" : None
@@ -621,6 +718,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
621
718
  contact = to
622
719
  prev_contact = None
623
720
 
721
+ scope = await set_scope(mc, "*")
722
+ prev_scope = scope
723
+
624
724
  await get_contacts(mc, anim=True)
625
725
  await get_channels(mc, anim=True)
626
726
  await subscribe_to_msgs(mc, above=True)
@@ -659,6 +759,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
659
759
 
660
760
  last_ack = True
661
761
  while True:
762
+ # reset scope (if changed)
763
+ scope = await set_scope(mc, scope)
764
+
662
765
  color = process_event_message.color
663
766
  classic = interactive_loop.classic or not color
664
767
  print_name = interactive_loop.print_name
@@ -668,14 +771,17 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
668
771
  else:
669
772
  prompt = f"{ANSI_INVERT}"
670
773
 
671
- # some possible symbols for prompts 🭬🬛🬗🭬🬛🬃🬗🭬🬛🬃🬗🬏🭀🭋🭨🮋
672
774
  if print_name or contact is None :
673
- prompt = prompt + f"{ANSI_BGRAY}"
775
+ if color:
776
+ prompt = prompt + f"{ANSI_BGRAY}"
674
777
  prompt = prompt + f"{mc.self_info['name']}"
778
+ if contact is None: # display scope
779
+ if not scope is None:
780
+ prompt = prompt + f"|{scope}"
675
781
  if classic :
676
- prompt = prompt + " > "
782
+ prompt = prompt + "> "
677
783
  else :
678
- prompt = prompt + "🭨"
784
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}{ANSI_INVERT}"
679
785
 
680
786
  if not contact is None :
681
787
  if not last_ack:
@@ -696,13 +802,24 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
696
802
  prompt = prompt + f"{ANSI_INVERT}"
697
803
 
698
804
  if print_name and not classic :
699
- prompt = prompt + "🭬"
805
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_TAIL}{ANSI_INVERT}"
700
806
 
701
807
  prompt = prompt + f"{contact['adv_name']}"
808
+ if contact["type"] == 0 or contact["out_path_len"]==-1:
809
+ if scope is None:
810
+ prompt = prompt + f"|*"
811
+ else:
812
+ prompt = prompt + f"|{scope}"
813
+ else: # display path to dest or 0 if 0 hop
814
+ if contact["out_path_len"] == 0:
815
+ prompt = prompt + f"|0"
816
+ else:
817
+ prompt = prompt + "|" + contact["out_path"]
818
+
702
819
  if classic :
703
- prompt = prompt + f"{ANSI_NORMAL} > "
820
+ prompt = prompt + f"{ANSI_NORMAL}> "
704
821
  else:
705
- prompt = prompt + f"{ANSI_NORMAL}🭬"
822
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}"
706
823
 
707
824
  prompt = prompt + f"{ANSI_END}"
708
825
 
@@ -723,36 +840,91 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
723
840
  completer=completer,
724
841
  key_bindings=bindings)
725
842
 
843
+ line = line.strip()
844
+
726
845
  if line == "" : # blank line
727
846
  pass
728
847
 
848
+ elif line.startswith("?") :
849
+ get_help_for(line[1:], context="chat")
850
+
729
851
  # raw meshcli command as on command line
730
852
  elif line.startswith("$") :
731
- args = shlex.split(line[1:])
732
- await process_cmds(mc, args)
853
+ try :
854
+ args = shlex.split(line[1:])
855
+ await process_cmds(mc, args)
856
+ except ValueError:
857
+ logger.error("Error parsing line {line[1:]}")
858
+
859
+ elif line.startswith("/scope") or\
860
+ line.startswith("scope") and contact is None:
861
+ if not scope is None:
862
+ prev_scope = scope
863
+ try:
864
+ newscope = line.split(" ", 1)[1]
865
+ scope = await set_scope(mc, newscope)
866
+ except IndexError:
867
+ print(scope)
868
+
869
+ elif contact is None and (line.startswith("apply_to ") or line.startswith("at ")) or\
870
+ line.startswith("/apply_to ") or line.startswith("/at ") :
871
+ try:
872
+ await apply_command_to_contacts(mc, line.split(" ",2)[1], line.split(" ",2)[2])
873
+ except IndexError:
874
+ logger.error(f"Error with apply_to command parameters")
733
875
 
734
876
  elif line.startswith("/") :
735
877
  path = line.split(" ", 1)[0]
736
878
  if path.count("/") == 1:
737
- args = shlex.split(line[1:])
738
- await process_cmds(mc, args)
879
+ args = line[1:].split(" ")
880
+ dest = args[0]
881
+ dest_scope = None
882
+ if "%" in dest :
883
+ dest_scope = dest.split("%")[-1]
884
+ dest = dest[:-len(dest_scope)-1]
885
+ await set_scope (mc, dest_scope)
886
+ tct = mc.get_contact_by_name(dest)
887
+ if len(args)>1 and not tct is None: # a contact, send a message
888
+ if tct["type"] == 1 or tct["type"] == 3: # client or room
889
+ last_ack = await msg_ack(mc, tct, line.split(" ", 1)[1])
890
+ else:
891
+ print("Can only send msg to chan, client or room")
892
+ else :
893
+ ch = await get_channel_by_name(mc, dest)
894
+ if len(args)>1 and not ch is None: # a channel, send message
895
+ await send_chan_msg(mc, ch["channel_idx"], line.split(" ", 1)[1])
896
+ else :
897
+ try :
898
+ await process_cmds(mc, shlex.split(line[1:]))
899
+ except ValueError:
900
+ logger.error(f"Error processing line{line[1:]}")
739
901
  else:
740
902
  cmdline = line[1:].split("/",1)[1]
741
903
  contact_name = path[1:].split("/",1)[0]
904
+ dest_scope = None
905
+ if "%" in contact_name:
906
+ dest_scope = contact_name.split("%")[-1]
907
+ contact_name = contact_name[:-len(dest_scope)-1]
908
+ await set_scope (mc, dest_scope)
742
909
  tct = mc.get_contact_by_name(contact_name)
743
910
  if tct is None:
744
911
  print(f"{contact_name} is not a contact")
745
912
  else:
746
913
  if not await process_contact_chat_line(mc, tct, cmdline):
747
- if tct["type"] == 1:
748
- last_ack = await msg_ack(mc, tct, cmdline)
749
- else :
750
- await process_cmds(mc, ["cmd", tct["adv_name"], cmdline])
914
+ if cmdline != "":
915
+ if tct["type"] == 1:
916
+ last_ack = await msg_ack(mc, tct, cmdline)
917
+ else :
918
+ await process_cmds(mc, ["cmd", tct["adv_name"], cmdline])
751
919
 
752
920
  elif line.startswith("to ") : # dest
753
921
  dest = line[3:]
754
922
  if dest.startswith("\"") or dest.startswith("\'") : # if name starts with a quote
755
923
  dest = shlex.split(dest)[0] # use shlex.split to get contact name between quotes
924
+ dest_scope = None
925
+ if '%' in dest and scope!=None :
926
+ dest_scope = dest.split("%")[-1]
927
+ dest = dest[:-len(dest_scope)-1]
756
928
  nc = mc.get_contact_by_name(dest)
757
929
  if nc is None:
758
930
  if dest == "public" :
@@ -766,6 +938,8 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
766
938
  nc["adv_name"] = mc.channels[dest]["channel_name"]
767
939
  elif dest == ".." : # previous recipient
768
940
  nc = prev_contact
941
+ if dest_scope is None and not prev_scope is None:
942
+ dest_scope = prev_scope
769
943
  elif dest == "~" or dest == "/" or dest == mc.self_info['name']:
770
944
  nc = None
771
945
  elif dest == "!" :
@@ -783,6 +957,12 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
783
957
  last_ack = True
784
958
  prev_contact = contact
785
959
  contact = nc
960
+ if dest_scope is None:
961
+ dest_scope = scope
962
+ if not scope is None and dest_scope != scope:
963
+ prev_scope = scope
964
+ if not dest_scope is None:
965
+ scope = await set_scope(mc, dest_scope)
786
966
 
787
967
  elif line == "to" :
788
968
  if contact is None :
@@ -805,7 +985,7 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
805
985
  if ln is None :
806
986
  print("No received msg yet !")
807
987
  elif ln["type"] == 0 :
808
- await process_cmds(mc, ["chan", str(contact["chan_nb"]), line] )
988
+ await send_chan_msg(mc, ln["chan_nb"], line[1:])
809
989
  else :
810
990
  last_ack = await msg_ack(mc, ln, line[1:])
811
991
  if last_ack == False :
@@ -813,8 +993,11 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
813
993
 
814
994
  # commands are passed through if at root
815
995
  elif contact is None or line.startswith(".") :
816
- args = shlex.split(line)
817
- await process_cmds(mc, args)
996
+ try:
997
+ args = shlex.split(line)
998
+ await process_cmds(mc, args)
999
+ except ValueError:
1000
+ logger.error(f"Error processing {line}")
818
1001
 
819
1002
  elif await process_contact_chat_line(mc, contact, line):
820
1003
  pass
@@ -837,7 +1020,7 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
837
1020
  last_ack = await msg_ack(mc, contact, line)
838
1021
 
839
1022
  elif contact["type"] == 0 : # channel, send msg to channel
840
- await process_cmds(mc, ["chan", str(contact["chan_nb"]), line] )
1023
+ await send_chan_msg(mc, contact["chan_nb"], line)
841
1024
 
842
1025
  elif contact["type"] == 1 : # chat, send to recipient and wait ack
843
1026
  last_ack = await msg_ack(mc, contact, line)
@@ -859,6 +1042,12 @@ async def process_contact_chat_line(mc, contact, line):
859
1042
  if contact["type"] == 0:
860
1043
  return False
861
1044
 
1045
+ # if one element in line (most cases) strip the scope and apply it
1046
+ if not " " in line and "%" in line:
1047
+ dest_scope = line.split("%")[-1]
1048
+ line = line[:-len(dest_scope)-1]
1049
+ await set_scope (mc, dest_scope)
1050
+
862
1051
  if line.startswith(":") : # : will send a command to current recipient
863
1052
  args=["cmd", contact['adv_name'], line[1:]]
864
1053
  await process_cmds(mc, args)
@@ -869,6 +1058,23 @@ async def process_contact_chat_line(mc, contact, line):
869
1058
  await process_cmds(mc, args)
870
1059
  return True
871
1060
 
1061
+ if line.startswith("contact_name") or line.startswith("cn"):
1062
+ print(contact['adv_name'],end="")
1063
+ if " " in line:
1064
+ print(" ", end="", flush=True)
1065
+ secline = line.split(" ", 1)[1]
1066
+ await process_contact_chat_line(mc, contact, secline)
1067
+ else:
1068
+ print("")
1069
+ return True
1070
+
1071
+ if line == "contact_lastmod":
1072
+ timestamp = contact["lastmod"]
1073
+ print(f"{contact['adv_name']} updated"
1074
+ f" {datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d at %H:%M:%S')}"
1075
+ f" ({timestamp})")
1076
+ return True
1077
+
872
1078
  # commands that take contact as second arg will be sent to recipient
873
1079
  if line == "sc" or line == "share_contact" or\
874
1080
  line == "ec" or line == "export_contact" or\
@@ -956,28 +1162,45 @@ async def process_contact_chat_line(mc, contact, line):
956
1162
  return True
957
1163
 
958
1164
  # same but for commands with a parameter
959
- if line.startswith("cmd ") or\
960
- line.startswith("cp ") or line.startswith("change_path ") or\
961
- line.startswith("cf ") or line.startswith("change_flags ") or\
962
- line.startswith("req_binary ") or\
963
- line.startswith("login ") :
1165
+ if " " in line:
964
1166
  cmds = line.split(" ", 1)
965
- args = [cmds[0], contact['adv_name'], cmds[1]]
966
- await process_cmds(mc, args)
967
- return True
1167
+ if "%" in cmds[0]:
1168
+ dest_scope = cmds[0].split("%")[-1]
1169
+ cmds[0] = cmds[0][:-len(dest_scope)-1]
1170
+ await set_scope(mc, dest_scope)
1171
+
1172
+ if cmds[0] == "cmd" or cmds[0] == "msg" or\
1173
+ cmds[0] == "cp" or cmds[0] == "change_path" or\
1174
+ cmds[0] == "cf" or cmds[0] == "change_flags" or\
1175
+ cmds[0] == "req_binary" or\
1176
+ cmds[0] == "login" :
1177
+ args = [cmds[0], contact['adv_name'], cmds[1]]
1178
+ await process_cmds(mc, args)
1179
+ return True
968
1180
 
969
1181
  if line == "login": # use stored password or prompt for it
970
1182
  password_file = ""
971
1183
  password = ""
972
1184
  if os.path.isdir(MCCLI_CONFIG_DIR) :
1185
+ # if a password file exists with node name open it and destroy it
973
1186
  password_file = MCCLI_CONFIG_DIR + contact['adv_name'] + ".pass"
1187
+ if os.path.exists(password_file) :
1188
+ with open(password_file, "r", encoding="utf-8") as f :
1189
+ password=f.readline().strip()
1190
+ os.remove(password_file)
1191
+ password_file = MCCLI_CONFIG_DIR + contact["public_key"] + ".pass"
1192
+ with open(password_file, "w", encoding="utf-8") as f :
1193
+ f.write(password)
1194
+
1195
+ # this is the new correct password file, using pubkey
1196
+ password_file = MCCLI_CONFIG_DIR + contact["public_key"] + ".pass"
974
1197
  if os.path.exists(password_file) :
975
1198
  with open(password_file, "r", encoding="utf-8") as f :
976
1199
  password=f.readline().strip()
977
1200
 
978
1201
  if password == "":
979
1202
  try:
980
- sess = PromptSession("Password: ", is_password=True)
1203
+ sess = PromptSession(f"Password for {contact['adv_name']}: ", is_password=True)
981
1204
  password = await sess.prompt_async()
982
1205
  except EOFError:
983
1206
  logger.info("Canceled")
@@ -993,6 +1216,9 @@ async def process_contact_chat_line(mc, contact, line):
993
1216
 
994
1217
  if line.startswith("forget_password") or line.startswith("fp"):
995
1218
  password_file = MCCLI_CONFIG_DIR + contact['adv_name'] + ".pass"
1219
+ if os.path.exists(password_file):
1220
+ os.remove(password_file)
1221
+ password_file = MCCLI_CONFIG_DIR + contact['public_key'] + ".pass"
996
1222
  if os.path.exists(password_file):
997
1223
  os.remove(password_file)
998
1224
  try:
@@ -1013,6 +1239,89 @@ async def process_contact_chat_line(mc, contact, line):
1013
1239
 
1014
1240
  return False
1015
1241
 
1242
+ async def apply_command_to_contacts(mc, contact_filter, line):
1243
+ upd_before = None
1244
+ upd_after = None
1245
+ contact_type = None
1246
+ min_hops = None
1247
+ max_hops = None
1248
+
1249
+ await mc.ensure_contacts()
1250
+
1251
+ filters = contact_filter.split(",")
1252
+ for f in filters:
1253
+ if f == "all":
1254
+ pass
1255
+ elif f[0] == "u": #updated
1256
+ val_str = f[2:]
1257
+ t = time.time()
1258
+ if val_str[-1] == "d": # value in days
1259
+ t = t - float(val_str[0:-1]) * 86400
1260
+ elif val_str[-1] == "h": # value in hours
1261
+ t = t - float(val_str[0:-1]) * 3600
1262
+ elif val_str[-1] == "m": # value in minutes
1263
+ t = t - float(val_str[0:-1]) * 60
1264
+ else:
1265
+ t = int(val_str)
1266
+ if f[1] == "<": #before
1267
+ upd_before = t
1268
+ elif f[1] == ">":
1269
+ upd_after = t
1270
+ else:
1271
+ logger.error(f"Time filter can only be < or >")
1272
+ return
1273
+ elif f[0] == "t": # type
1274
+ if f[1] == "=":
1275
+ contact_type = int(f[2:])
1276
+ else:
1277
+ logger.error(f"Type can only be equals to a value")
1278
+ return
1279
+ elif f[0] == "d": # direct
1280
+ min_hops=0
1281
+ elif f[0] == "f": # flood
1282
+ max_hops=-1
1283
+ elif f[0] == "h": # hop number
1284
+ if f[1] == ">":
1285
+ min_hops = int(f[2:])+1
1286
+ elif f[1] == "<":
1287
+ max_hops = int(f[2:])-1
1288
+ elif f[1] == "=":
1289
+ min_hops = int(f[2:])
1290
+ max_hops = int(f[2:])
1291
+ else:
1292
+ logger.error(f"Unknown filter {f}")
1293
+ return
1294
+
1295
+ for c in dict(mc._contacts).items():
1296
+ contact = c[1]
1297
+ if (contact_type is None or contact["type"] == contact_type) and\
1298
+ (upd_before is None or contact["lastmod"] < upd_before) and\
1299
+ (upd_after is None or contact["lastmod"] > upd_after) and\
1300
+ (min_hops is None or contact["out_path_len"] >= min_hops) and\
1301
+ (max_hops is None or contact["out_path_len"] <= max_hops):
1302
+ if await process_contact_chat_line(mc, contact, line):
1303
+ pass
1304
+
1305
+ elif line == "remove_contact":
1306
+ args = [line, contact['adv_name']]
1307
+ await process_cmds(mc, args)
1308
+
1309
+ elif line.startswith("send") or line.startswith("\"") :
1310
+ if line.startswith("send") :
1311
+ line = line[5:]
1312
+ if line.startswith("\"") :
1313
+ line = line[1:]
1314
+ await msg_ack(mc, contact, line)
1315
+
1316
+ elif contact["type"] == 2 or\
1317
+ contact["type"] == 3 or\
1318
+ contact["type"] == 4 : # repeater, room, sensor send cmd
1319
+ await process_cmds(mc, ["cmd", contact["adv_name"], line])
1320
+ # wait for a reply from cmd
1321
+ await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=7)
1322
+
1323
+ else:
1324
+ logger.error(f"Can't send {line} to {contact['adv_name']}")
1016
1325
 
1017
1326
  async def send_cmd (mc, contact, cmd) :
1018
1327
  res = await mc.commands.send_cmd(contact, cmd)
@@ -1056,7 +1365,7 @@ async def send_msg (mc, contact, msg) :
1056
1365
 
1057
1366
  async def msg_ack (mc, contact, msg) :
1058
1367
  timeout = 0 if not 'timeout' in contact else contact['timeout']
1059
- res = await mc.commands.send_msg_with_retry(contact, msg,
1368
+ res = await mc.commands.send_msg_with_retry(contact, msg,
1060
1369
  max_attempts=msg_ack.max_attempts,
1061
1370
  flood_after=msg_ack.flood_after,
1062
1371
  max_flood_attempts=msg_ack.max_flood_attempts,
@@ -1076,6 +1385,28 @@ msg_ack.max_attempts=3
1076
1385
  msg_ack.flood_after=2
1077
1386
  msg_ack.max_flood_attempts=1
1078
1387
 
1388
+ async def set_scope (mc, scope) :
1389
+ if not set_scope.has_scope:
1390
+ return None
1391
+
1392
+ if scope == "None" or scope == "0" or scope == "clear" or scope == "":
1393
+ scope = "*"
1394
+
1395
+ if set_scope.current_scope == scope:
1396
+ return scope
1397
+
1398
+ res = await mc.commands.set_flood_scope(scope)
1399
+ if res is None or res.type == EventType.ERROR:
1400
+ if not res is None and res.payload["error_code"] == 1: #unsupported
1401
+ set_scope.has_scope = False
1402
+ return None
1403
+
1404
+ set_scope.current_scope = scope
1405
+
1406
+ return scope
1407
+ set_scope.has_scope = True
1408
+ set_scope.current_scope = None
1409
+
1079
1410
  async def get_channel (mc, chan) :
1080
1411
  if not chan.isnumeric():
1081
1412
  return await get_channel_by_name(mc, chan)
@@ -1112,6 +1443,7 @@ async def set_channel (mc, chan, name, key=None):
1112
1443
  return None
1113
1444
 
1114
1445
  info = res.payload
1446
+ info["channel_hash"] = SHA256.new(info["channel_secret"]).hexdigest()[0:2]
1115
1447
  info["channel_secret"] = info["channel_secret"].hex()
1116
1448
 
1117
1449
  if hasattr(mc,'channels') :
@@ -1200,12 +1532,14 @@ async def get_channels (mc, anim=False) :
1200
1532
  if res.type == EventType.ERROR:
1201
1533
  break
1202
1534
  info = res.payload
1535
+ info["channel_hash"] = SHA256.new(info["channel_secret"]).hexdigest()[0:2]
1203
1536
  info["channel_secret"] = info["channel_secret"].hex()
1204
1537
  mc.channels.append(info)
1205
1538
  ch = ch + 1
1206
1539
  if anim:
1207
1540
  print(".", end="", flush=True)
1208
- print (" Done")
1541
+ if anim:
1542
+ print (" Done")
1209
1543
  return mc.channels
1210
1544
 
1211
1545
  async def print_trace_to (mc, contact):
@@ -1279,8 +1613,14 @@ async def print_disc_trace_to (mc, contact):
1279
1613
 
1280
1614
  async def next_cmd(mc, cmds, json_output=False):
1281
1615
  """ process next command """
1616
+ global ARROW_TAIL, ARROW_HEAD
1282
1617
  try :
1283
1618
  argnum = 0
1619
+
1620
+ if cmds[0].startswith("?") : # get some help
1621
+ get_help_for(cmds[0][1:], context="line")
1622
+ return cmds[argnum+1:]
1623
+
1284
1624
  if cmds[0].startswith(".") : # override json_output
1285
1625
  json_output = True
1286
1626
  cmd = cmds[0][1:]
@@ -1371,6 +1711,10 @@ async def next_cmd(mc, cmds, json_output=False):
1371
1711
  else:
1372
1712
  print("Time set")
1373
1713
 
1714
+ case "apply_to"|"at":
1715
+ argnum = 2
1716
+ await apply_command_to_contacts(mc, cmds[1], cmds[2])
1717
+
1374
1718
  case "set":
1375
1719
  argnum = 2
1376
1720
  match cmds[1]:
@@ -1403,6 +1747,10 @@ async def next_cmd(mc, cmds, json_output=False):
1403
1747
  interactive_loop.classic = (cmds[2] == "on")
1404
1748
  if json_output :
1405
1749
  print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1750
+ case "arrow_tail":
1751
+ ARROW_TAIL = cmds[2]
1752
+ case "arrow_head":
1753
+ ARROW_HEAD = cmds[2]
1406
1754
  case "color" :
1407
1755
  process_event_message.color = (cmds[2] == "on")
1408
1756
  if json_output :
@@ -1411,6 +1759,18 @@ async def next_cmd(mc, cmds, json_output=False):
1411
1759
  process_event_message.print_snr = (cmds[2] == "on")
1412
1760
  if json_output :
1413
1761
  print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1762
+ case "json_log_rx" :
1763
+ handle_log_rx.json_log_rx = (cmds[2] == "on")
1764
+ if json_output :
1765
+ print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1766
+ case "channel_echoes" :
1767
+ handle_log_rx.channel_echoes = (cmds[2] == "on")
1768
+ if json_output :
1769
+ print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1770
+ case "echo_unk_chans" :
1771
+ handle_log_rx.echo_unk_chans = (cmds[2] == "on")
1772
+ if json_output :
1773
+ print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1414
1774
  case "print_adverts" :
1415
1775
  handle_advert.print_adverts = (cmds[2] == "on")
1416
1776
  if json_output :
@@ -1641,6 +2001,21 @@ async def next_cmd(mc, cmds, json_output=False):
1641
2001
  print(json.dumps({"color" : process_event_message.color}))
1642
2002
  else:
1643
2003
  print(f"{'on' if process_event_message.color else 'off'}")
2004
+ case "json_log_rx":
2005
+ if json_output :
2006
+ print(json.dumps({"json_log_rx" : handle_log_rx.json_log_rx}))
2007
+ else:
2008
+ print(f"{'on' if handle_log_rx.json_log_rx else 'off'}")
2009
+ case "channel_echoes":
2010
+ if json_output :
2011
+ print(json.dumps({"channel_echoes" : handle_log_rx.channel_echoes}))
2012
+ else:
2013
+ print(f"{'on' if handle_log_rx.channel_echoes else 'off'}")
2014
+ case "echo_unk_chans":
2015
+ if json_output :
2016
+ print(json.dumps({"echo_unk_chans" : handle_log_rx.echo_unk_chans}))
2017
+ else:
2018
+ print(f"{'on' if handle_log_rx.echo_unk_chans else 'off'}")
1644
2019
  case "print_adverts":
1645
2020
  if json_output :
1646
2021
  print(json.dumps({"print_adverts" : handle_advert.print_adverts}))
@@ -1838,6 +2213,12 @@ async def next_cmd(mc, cmds, json_output=False):
1838
2213
  if res is None:
1839
2214
  print("Error setting channel")
1840
2215
 
2216
+ case "scope":
2217
+ argnum = 1
2218
+ res = await set_scope(mc, cmds[1])
2219
+ if res is None:
2220
+ print(f"Error while setting scope")
2221
+
1841
2222
  case "remove_channel":
1842
2223
  argnum = 1
1843
2224
  res = await set_channel(mc, cmds[1], "", bytes.fromhex(16*"00"))
@@ -1904,7 +2285,7 @@ async def next_cmd(mc, cmds, json_output=False):
1904
2285
  argnum = 2
1905
2286
  dest = None
1906
2287
 
1907
- if len(cmds[1]) == 12: # possibly an hex prefix
2288
+ if len(cmds[1]) == 12: # possibly an hex prefix
1908
2289
  try:
1909
2290
  dest = bytes.fromhex(cmds[1])
1910
2291
  except ValueError:
@@ -1959,7 +2340,8 @@ async def next_cmd(mc, cmds, json_output=False):
1959
2340
  if json_output:
1960
2341
  print(json.dumps(ev.payload, indent=2))
1961
2342
  else :
1962
- classic = interactive_loop.classic or not process_event_message.color
2343
+ color = process_event_message.color
2344
+ classic = interactive_loop.classic or not color
1963
2345
  print("]",end="")
1964
2346
  for t in ev.payload["path"]:
1965
2347
  if classic :
@@ -1967,18 +2349,20 @@ async def next_cmd(mc, cmds, json_output=False):
1967
2349
  else:
1968
2350
  print(f" {ANSI_INVERT}", end="")
1969
2351
  snr = t['snr']
1970
- if snr >= 10 :
1971
- print(ANSI_BGREEN, end="")
1972
- elif snr <= 0:
1973
- print(ANSI_BRED, end="")
1974
- else :
1975
- print(ANSI_BGRAY, end="")
2352
+ if color:
2353
+ if snr >= 10 :
2354
+ print(ANSI_BGREEN, end="")
2355
+ elif snr <= 0:
2356
+ print(ANSI_BRED, end="")
2357
+ else :
2358
+ print(ANSI_BGRAY, end="")
1976
2359
  print(f"{snr:.2f}",end="")
1977
2360
  if classic :
1978
2361
  print("→",end="")
1979
2362
  else :
1980
- print(f"{ANSI_NORMAL}🭬",end="")
1981
- print(ANSI_END, end="")
2363
+ print(f"{ANSI_NORMAL}{ARROW_HEAD}",end="")
2364
+ if color:
2365
+ print(ANSI_END, end="")
1982
2366
  if "hash" in t:
1983
2367
  print(f"[{t['hash']}]",end="")
1984
2368
  else:
@@ -2102,6 +2486,71 @@ async def next_cmd(mc, cmds, json_output=False):
2102
2486
  inp = inp if inp != "" else "direct"
2103
2487
  print(f"Path for {contact['adv_name']}: out {outp}, in {inp}")
2104
2488
 
2489
+ case "node_discover"|"nd" :
2490
+ argnum = 1
2491
+ prefix_only = True
2492
+
2493
+ if len(cmds) == 1:
2494
+ argnum = 0
2495
+ types = 0xFF
2496
+ else:
2497
+ try: # try to decode type as int
2498
+ types = int(cmds[1])
2499
+ except ValueError:
2500
+ if "all" in cmds[1]:
2501
+ types = 0xFF
2502
+ else :
2503
+ types = 0
2504
+ if "rep" in cmds[1] or "rpt" in cmds[1]:
2505
+ types = types | 4
2506
+ if "cli" in cmds[1] or "comp" in cmds[1]:
2507
+ types = types | 2
2508
+ if "room" in cmds[1]:
2509
+ types = types | 8
2510
+ if "sens" in cmds[1]:
2511
+ types = types | 16
2512
+
2513
+ if "full" in cmds[1]:
2514
+ prefix_only = False
2515
+
2516
+ res = await mc.commands.send_node_discover_req(types, prefix_only=prefix_only)
2517
+ if res is None or res.type == EventType.ERROR:
2518
+ print("Error sending discover request")
2519
+ else:
2520
+ exp_tag = res.payload["tag"].to_bytes(4, "little").hex()
2521
+ dn = []
2522
+ while True:
2523
+ r = await mc.wait_for_event(
2524
+ EventType.DISCOVER_RESPONSE,
2525
+ attribute_filters={"tag":exp_tag},
2526
+ timeout = 5
2527
+ )
2528
+ if r is None or r.type == EventType.ERROR:
2529
+ break
2530
+ else:
2531
+ dn.append(r.payload)
2532
+
2533
+ if json_output:
2534
+ print(json.dumps(dn))
2535
+ else:
2536
+ await mc.ensure_contacts()
2537
+ print(f"Discovered {len(dn)} nodes:")
2538
+ for n in dn:
2539
+ name = f"{n['pubkey'][0:2]} {mc.get_contact_by_key_prefix(n['pubkey'])['adv_name']}"
2540
+ if name is None:
2541
+ name = n["pubkey"][0:16]
2542
+ type = f"t:{n['node_type']}"
2543
+ if n['node_type'] == 1:
2544
+ type = "CLI"
2545
+ elif n['node_type'] == 2:
2546
+ type = "REP"
2547
+ elif n['node_type'] == 3:
2548
+ type = "ROOM"
2549
+ elif n['node_type'] == 4:
2550
+ type = "SENS"
2551
+
2552
+ print(f" {name:16} {type:>4} SNR: {n['SNR_in']:6,.2f}->{n['SNR']:6,.2f} RSSI: ->{n['RSSI']:4}")
2553
+
2105
2554
  case "req_btelemetry"|"rbt" :
2106
2555
  argnum = 1
2107
2556
  await mc.ensure_contacts()
@@ -2232,6 +2681,13 @@ async def next_cmd(mc, cmds, json_output=False):
2232
2681
  case "add_pending":
2233
2682
  argnum = 1
2234
2683
  contact = mc.pop_pending_contact(cmds[1])
2684
+ if contact is None: # try to find by name
2685
+ key = None
2686
+ for c in mc.pending_contacts.items():
2687
+ if c[1]['adv_name'] == cmds[1]:
2688
+ key = c[1]['public_key']
2689
+ contact = mc.pop_pending_contact(key)
2690
+ break
2235
2691
  if contact is None:
2236
2692
  if json_output:
2237
2693
  print(json.dumps({"error":"Contact does not exist"}))
@@ -2511,7 +2967,7 @@ async def next_cmd(mc, cmds, json_output=False):
2511
2967
  if json_output:
2512
2968
  await ps.prompt_async()
2513
2969
  else:
2514
- await ps.prompt_async("Press Enter to continue ...")
2970
+ await ps.prompt_async("Press Enter to continue ...\n")
2515
2971
  except (EOFError, KeyboardInterrupt, asyncio.CancelledError):
2516
2972
  pass
2517
2973
 
@@ -2570,7 +3026,7 @@ async def next_cmd(mc, cmds, json_output=False):
2570
3026
  await mc.ensure_contacts()
2571
3027
  contact = mc.get_contact_by_name(cmds[0])
2572
3028
  if contact is None:
2573
- logger.error(f"Unknown command : {cmd}, will exit ...")
3029
+ logger.error(f"Unknown command : {cmd}, {cmds} not executed ...")
2574
3030
  return None
2575
3031
 
2576
3032
  await interactive_loop(mc, to=contact)
@@ -2579,7 +3035,7 @@ async def next_cmd(mc, cmds, json_output=False):
2579
3035
  return cmds[argnum+1:]
2580
3036
 
2581
3037
  except IndexError:
2582
- logger.error("Error in parameters, returning")
3038
+ logger.error("Error in parameters")
2583
3039
  return None
2584
3040
  except EOFError:
2585
3041
  logger.error("Cancelled")
@@ -2604,14 +3060,18 @@ async def process_script(mc, file, json_output=False):
2604
3060
  line = line.strip()
2605
3061
  if not (line == "" or line[0] == "#"):
2606
3062
  logger.debug(f"processing {line}")
2607
- cmds = shlex.split(line)
2608
- await process_cmds(mc, cmds, json_output)
3063
+ try :
3064
+ cmds = shlex.split(line)
3065
+ await process_cmds(mc, cmds, json_output)
3066
+ except ValueError:
3067
+ logger.error(f"Error processing {line}")
2609
3068
 
2610
3069
  def version():
2611
3070
  print (f"meshcore-cli: command line interface to MeshCore companion radios {VERSION}")
2612
3071
 
2613
3072
  def command_help():
2614
- print(""" General commands
3073
+ print(""" ?<cmd> may give you some more help about cmd
3074
+ General commands
2615
3075
  chat : enter the chat (interactive) mode
2616
3076
  chat_to <ct> : enter chat with contact to
2617
3077
  script <filename> : execute commands in filename
@@ -2622,6 +3082,7 @@ def command_help():
2622
3082
  reboot : reboots node
2623
3083
  sleep <secs> : sleeps for a given amount of secs s
2624
3084
  wait_key : wait until user presses <Enter> wk
3085
+ apply_to <scope> <cmds>: sends cmds to contacts matching scope at
2625
3086
  Messenging
2626
3087
  msg <name> <msg> : send message to node by name m {
2627
3088
  wait_ack : wait an ack wa }
@@ -2643,6 +3104,7 @@ def command_help():
2643
3104
  time <epoch> : sets time to given epoch
2644
3105
  clock : get current time
2645
3106
  clock sync : sync device clock st
3107
+ node_discover <filter> : discovers nodes based on their type nd
2646
3108
  Contacts
2647
3109
  contacts / list : gets contact list lc
2648
3110
  reload_contacts : force reloading all contacts rc
@@ -2661,7 +3123,7 @@ def command_help():
2661
3123
  req_mma <ct> : requests min/max/avg for a sensor rm
2662
3124
  req_acl <ct> : requests access control list for sensor
2663
3125
  pending_contacts : show pending contacts
2664
- add_pending <key> : manually add pending contact from key
3126
+ add_pending <pending> : manually add pending contact
2665
3127
  flush_pending : flush pending contact list
2666
3128
  Repeaters
2667
3129
  login <name> <pwd> : log into a node (rep) with given pwd l
@@ -2684,6 +3146,7 @@ def usage () :
2684
3146
  -D : debug
2685
3147
  -S : scan for devices and show a selector
2686
3148
  -l : list available ble/serial devices and exit
3149
+ -c <on/off> : disables most of color output if off
2687
3150
  -T <timeout> : timeout for the ble scan (-S and -l) default 2s
2688
3151
  -a <address> : specifies device address (can be a name)
2689
3152
  -d <name> : filter meshcore devices with name or address
@@ -2696,6 +3159,43 @@ def usage () :
2696
3159
  Available Commands and shorcuts (can be chained) :""")
2697
3160
  command_help()
2698
3161
 
3162
+ def get_help_for (cmdname, context="line") :
3163
+ if cmdname == "apply_to" or cmdname == "at" :
3164
+ print("""apply_to <scope> <cmd> : applies cmd to contacts matching scope
3165
+ Scope acts like a filter with comma separated fields :
3166
+ - u, matches modification time < or > than a timestamp
3167
+ (can also be days hours or minutes ago if followed by d,h or m)
3168
+ - t, matches the type (1: client, 2: repeater, 3: room, 4: sensor)
3169
+ - h, matches number of hops
3170
+ - d, direct, similar to h>-1
3171
+ - f, flood, similar to h<0 or h=-1
3172
+
3173
+ Note: Some commands like contact_name (aka cn), reset_path (aka rp), forget_password (aka fp) can be chained.
3174
+
3175
+ Examples:
3176
+ # removes all clients that have not been updated in last 2 days
3177
+ at u<2d,t=1 remove_contact
3178
+ # gives traces to repeaters that have been updated in the last 24h and are direct
3179
+ at t=2,u>1d,d cn trace
3180
+ # tries to do flood login to all repeaters
3181
+ at t=2 rp login
3182
+ """)
3183
+
3184
+ if cmdname == "node_discover" or cmdname == "nd" :
3185
+ print("""node_discover <filter> : discovers 0-hop nodes and displays signal info
3186
+
3187
+ filter can be "all" for all types or nodes or a comma separated list consisting of :
3188
+ - cli or comp for companions
3189
+ - rep for repeaters
3190
+ - sens for sensors
3191
+ - room for chat rooms
3192
+
3193
+ nd can be used with no filter parameter ... !!! BEWARE WITH CHAINING !!!
3194
+ """)
3195
+
3196
+ else:
3197
+ print(f"Sorry, no help yet for {cmdname}")
3198
+
2699
3199
  async def main(argv):
2700
3200
  """ Do the job """
2701
3201
  json_output = JSON
@@ -2714,9 +3214,12 @@ async def main(argv):
2714
3214
  with open(MCCLI_ADDRESS, encoding="utf-8") as f :
2715
3215
  address = f.readline().strip()
2716
3216
 
2717
- opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:P")
3217
+ opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:Pc:")
2718
3218
  for opt, arg in opts :
2719
3219
  match opt:
3220
+ case "-c" :
3221
+ if arg == "off":
3222
+ process_event_message.color = False
2720
3223
  case "-d" : # name specified on cmdline
2721
3224
  address = arg
2722
3225
  case "-a" : # address specified on cmdline
@@ -2881,10 +3384,12 @@ async def main(argv):
2881
3384
  handle_message.mc = mc # connect meshcore to handle_message
2882
3385
  handle_advert.mc = mc
2883
3386
  handle_path_update.mc = mc
3387
+ handle_log_rx.mc = mc
2884
3388
 
2885
3389
  mc.subscribe(EventType.ADVERTISEMENT, handle_advert)
2886
3390
  mc.subscribe(EventType.PATH_UPDATE, handle_path_update)
2887
3391
  mc.subscribe(EventType.NEW_CONTACT, handle_new_contact)
3392
+ mc.subscribe(EventType.RX_LOG_DATA, handle_log_rx)
2888
3393
 
2889
3394
  mc.auto_update_contacts = True
2890
3395
 
File without changes
File without changes
File without changes
File without changes
File without changes