meshcore-cli 1.2.5__tar.gz → 1.2.12__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.5
3
+ Version: 1.2.12
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,7 +10,7 @@ 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.24
14
14
  Requires-Dist: prompt-toolkit>=3.0.50
15
15
  Requires-Dist: pycryptodome
16
16
  Requires-Dist: requests>=2.28.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore-cli"
7
- version = "1.2.5"
7
+ version = "1.2.12"
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", "pycryptodome" ]
20
+ dependencies = [ "meshcore >= 2.1.24", "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,7 +24,6 @@ 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 hashlib import sha256
28
27
  from Crypto.Cipher import AES
29
28
  from Crypto.Hash import HMAC, SHA256
30
29
 
@@ -33,7 +32,7 @@ import re
33
32
  from meshcore import MeshCore, EventType, logger
34
33
 
35
34
  # Version
36
- VERSION = "v1.2.5"
35
+ VERSION = "v1.2.12"
37
36
 
38
37
  # default ble address is stored in a config file
39
38
  MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/"
@@ -76,6 +75,15 @@ ANSI_BORANGE="\033[1;38;5;214m"
76
75
  ANSI_YELLOW = "\033[0;33m"
77
76
  ANSI_BYELLOW = "\033[1;33m"
78
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
+
79
87
  def escape_ansi(line):
80
88
  ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
81
89
  return ansi_escape.sub('', line)
@@ -208,15 +216,19 @@ async def handle_log_rx(event):
208
216
  return
209
217
 
210
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
211
231
 
212
- if handle_log_rx.channel_echoes:
213
- if pkt[0] == 0x15:
214
- chan_name = ""
215
- path_len = pkt[1]
216
- path = pkt[2:path_len+2].hex()
217
- chan_hash = pkt[path_len+2:path_len+3].hex()
218
- cipher_mac = pkt[path_len+3:path_len+5]
219
- msg = pkt[path_len+5:]
220
232
  channel = None
221
233
  for c in await get_channels(mc):
222
234
  if c["channel_hash"] == chan_hash : # validate against MAC
@@ -226,6 +238,8 @@ async def handle_log_rx(event):
226
238
  channel = c
227
239
  break
228
240
 
241
+ chan_name = ""
242
+
229
243
  if channel is None :
230
244
  if handle_log_rx.echo_unk_chans:
231
245
  chan_name = chan_hash
@@ -235,7 +249,7 @@ async def handle_log_rx(event):
235
249
  aes_key = bytes.fromhex(channel["channel_secret"])
236
250
  cipher = AES.new(aes_key, AES.MODE_ECB)
237
251
  message = cipher.decrypt(msg)[5:].decode("utf-8").strip("\x00")
238
-
252
+
239
253
  if chan_name != "" :
240
254
  width = os.get_terminal_size().columns
241
255
  cars = width - 13 - 2 * path_len - len(chan_name) - 1
@@ -245,7 +259,7 @@ async def handle_log_rx(event):
245
259
  print_above(txt)
246
260
  else:
247
261
  print(txt)
248
-
262
+
249
263
  handle_log_rx.json_log_rx = False
250
264
  handle_log_rx.channel_echoes = False
251
265
  handle_log_rx.mc = None
@@ -322,7 +336,7 @@ async def log_message(mc, msg):
322
336
  if msg["type"] == "PRIV" :
323
337
  ct = mc.get_contact_by_key_prefix(msg['pubkey_prefix'])
324
338
  if ct is None:
325
- msg["name"] = data["pubkey_prefix"]
339
+ msg["name"] = msg["pubkey_prefix"]
326
340
  else:
327
341
  msg["name"] = ct["adv_name"]
328
342
  elif msg["type"] == "CHAN" :
@@ -366,7 +380,7 @@ async def subscribe_to_msgs(mc, json_output=False, above=False):
366
380
  class MyNestedCompleter(NestedCompleter):
367
381
  def get_completions( self, document, complete_event):
368
382
  txt = document.text_before_cursor.lstrip()
369
- if not " " in txt:
383
+ if not " " in txt:
370
384
  if txt != "" and txt[0] == "/" and txt.count("/") == 1:
371
385
  opts = []
372
386
  for k in self.options.keys():
@@ -445,6 +459,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
445
459
  "share_contact" : contact_list,
446
460
  "path": contact_list,
447
461
  "disc_path" : contact_list,
462
+ "node_discover": {"all":None, "sens":None, "rep":None, "comp":None, "room":None, "cli":None},
448
463
  "trace" : None,
449
464
  "reset_path" : contact_list,
450
465
  "change_path" : contact_list,
@@ -465,6 +480,9 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
465
480
  "set_channel": None,
466
481
  "get_channels": None,
467
482
  "remove_channel": None,
483
+ "apply_to": None,
484
+ "at": None,
485
+ "scope": None,
468
486
  "set" : {
469
487
  "name" : None,
470
488
  "pin" : None,
@@ -531,6 +549,8 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
531
549
 
532
550
  contact_completion_list = {
533
551
  "contact_info": None,
552
+ "contact_name": None,
553
+ "contact_lastmod": None,
534
554
  "export_contact" : None,
535
555
  "share_contact" : None,
536
556
  "upload_contact" : None,
@@ -558,6 +578,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
558
578
  "logout" : None,
559
579
  "req_status" : None,
560
580
  "req_bstatus" : None,
581
+ "req_neighbours": None,
561
582
  "cmd" : None,
562
583
  "ver" : None,
563
584
  "advert" : None,
@@ -569,6 +590,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
569
590
  "neighbors" : None,
570
591
  "req_acl":None,
571
592
  "setperm":contact_list,
593
+ "region" : {"get":None, "allowf": None, "denyf": None, "put": None, "remove": None, "save": None, "home": None},
572
594
  "gps" : {"on":None,"off":None,"sync":None,"setloc":None,
573
595
  "advert" : {"none": None, "share": None, "prefs": None},
574
596
  },
@@ -652,7 +674,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
652
674
  slash_root_completion_list = {}
653
675
  for k,v in root_completion_list.items():
654
676
  slash_root_completion_list["/"+k]=v
655
-
677
+
656
678
  completion_list.update(slash_root_completion_list)
657
679
 
658
680
  slash_contacts_completion_list = {}
@@ -697,6 +719,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
697
719
  contact = to
698
720
  prev_contact = None
699
721
 
722
+ scope = await set_scope(mc, "*")
723
+ prev_scope = scope
724
+
700
725
  await get_contacts(mc, anim=True)
701
726
  await get_channels(mc, anim=True)
702
727
  await subscribe_to_msgs(mc, above=True)
@@ -735,6 +760,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
735
760
 
736
761
  last_ack = True
737
762
  while True:
763
+ # reset scope (if changed)
764
+ scope = await set_scope(mc, scope)
765
+
738
766
  color = process_event_message.color
739
767
  classic = interactive_loop.classic or not color
740
768
  print_name = interactive_loop.print_name
@@ -744,14 +772,17 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
744
772
  else:
745
773
  prompt = f"{ANSI_INVERT}"
746
774
 
747
- # some possible symbols for prompts 🭬🬛🬗🭬🬛🬃🬗🭬🬛🬃🬗🬏🭀🭋🭨🮋
748
775
  if print_name or contact is None :
749
- prompt = prompt + f"{ANSI_BGRAY}"
776
+ if color:
777
+ prompt = prompt + f"{ANSI_BGRAY}"
750
778
  prompt = prompt + f"{mc.self_info['name']}"
779
+ if contact is None: # display scope
780
+ if not scope is None:
781
+ prompt = prompt + f"|{scope}"
751
782
  if classic :
752
- prompt = prompt + " > "
783
+ prompt = prompt + "> "
753
784
  else :
754
- prompt = prompt + f"{ANSI_NORMAL}🭬{ANSI_INVERT}"
785
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}{ANSI_INVERT}"
755
786
 
756
787
  if not contact is None :
757
788
  if not last_ack:
@@ -772,13 +803,24 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
772
803
  prompt = prompt + f"{ANSI_INVERT}"
773
804
 
774
805
  if print_name and not classic :
775
- prompt = prompt + f"{ANSI_NORMAL}🭨{ANSI_INVERT}"
806
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_TAIL}{ANSI_INVERT}"
776
807
 
777
808
  prompt = prompt + f"{contact['adv_name']}"
809
+ if contact["type"] == 0 or contact["out_path_len"]==-1:
810
+ if scope is None:
811
+ prompt = prompt + f"|*"
812
+ else:
813
+ prompt = prompt + f"|{scope}"
814
+ else: # display path to dest or 0 if 0 hop
815
+ if contact["out_path_len"] == 0:
816
+ prompt = prompt + f"|0"
817
+ else:
818
+ prompt = prompt + "|" + contact["out_path"]
819
+
778
820
  if classic :
779
- prompt = prompt + f"{ANSI_NORMAL} > "
821
+ prompt = prompt + f"{ANSI_NORMAL}> "
780
822
  else:
781
- prompt = prompt + f"{ANSI_NORMAL}🭬"
823
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}"
782
824
 
783
825
  prompt = prompt + f"{ANSI_END}"
784
826
 
@@ -799,9 +841,14 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
799
841
  completer=completer,
800
842
  key_bindings=bindings)
801
843
 
844
+ line = line.strip()
845
+
802
846
  if line == "" : # blank line
803
847
  pass
804
848
 
849
+ elif line.startswith("?") :
850
+ get_help_for(line[1:], context="chat")
851
+
805
852
  # raw meshcli command as on command line
806
853
  elif line.startswith("$") :
807
854
  try :
@@ -810,18 +857,41 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
810
857
  except ValueError:
811
858
  logger.error("Error parsing line {line[1:]}")
812
859
 
860
+ elif line.startswith("/scope") or\
861
+ line.startswith("scope") and contact is None:
862
+ if not scope is None:
863
+ prev_scope = scope
864
+ try:
865
+ newscope = line.split(" ", 1)[1]
866
+ scope = await set_scope(mc, newscope)
867
+ except IndexError:
868
+ print(scope)
869
+
870
+ elif contact is None and (line.startswith("apply_to ") or line.startswith("at ")) or\
871
+ line.startswith("/apply_to ") or line.startswith("/at ") :
872
+ try:
873
+ await apply_command_to_contacts(mc, line.split(" ",2)[1], line.split(" ",2)[2])
874
+ except IndexError:
875
+ logger.error(f"Error with apply_to command parameters")
876
+
813
877
  elif line.startswith("/") :
814
878
  path = line.split(" ", 1)[0]
815
879
  if path.count("/") == 1:
816
880
  args = line[1:].split(" ")
817
- tct = mc.get_contact_by_name(args[0])
881
+ dest = args[0]
882
+ dest_scope = None
883
+ if "%" in dest :
884
+ dest_scope = dest.split("%")[-1]
885
+ dest = dest[:-len(dest_scope)-1]
886
+ await set_scope (mc, dest_scope)
887
+ tct = mc.get_contact_by_name(dest)
818
888
  if len(args)>1 and not tct is None: # a contact, send a message
819
889
  if tct["type"] == 1 or tct["type"] == 3: # client or room
820
890
  last_ack = await msg_ack(mc, tct, line.split(" ", 1)[1])
821
891
  else:
822
892
  print("Can only send msg to chan, client or room")
823
893
  else :
824
- ch = await get_channel_by_name(mc, args[0])
894
+ ch = await get_channel_by_name(mc, dest)
825
895
  if len(args)>1 and not ch is None: # a channel, send message
826
896
  await send_chan_msg(mc, ch["channel_idx"], line.split(" ", 1)[1])
827
897
  else :
@@ -832,6 +902,11 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
832
902
  else:
833
903
  cmdline = line[1:].split("/",1)[1]
834
904
  contact_name = path[1:].split("/",1)[0]
905
+ dest_scope = None
906
+ if "%" in contact_name:
907
+ dest_scope = contact_name.split("%")[-1]
908
+ contact_name = contact_name[:-len(dest_scope)-1]
909
+ await set_scope (mc, dest_scope)
835
910
  tct = mc.get_contact_by_name(contact_name)
836
911
  if tct is None:
837
912
  print(f"{contact_name} is not a contact")
@@ -847,6 +922,10 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
847
922
  dest = line[3:]
848
923
  if dest.startswith("\"") or dest.startswith("\'") : # if name starts with a quote
849
924
  dest = shlex.split(dest)[0] # use shlex.split to get contact name between quotes
925
+ dest_scope = None
926
+ if '%' in dest and scope!=None :
927
+ dest_scope = dest.split("%")[-1]
928
+ dest = dest[:-len(dest_scope)-1]
850
929
  nc = mc.get_contact_by_name(dest)
851
930
  if nc is None:
852
931
  if dest == "public" :
@@ -860,6 +939,8 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
860
939
  nc["adv_name"] = mc.channels[dest]["channel_name"]
861
940
  elif dest == ".." : # previous recipient
862
941
  nc = prev_contact
942
+ if dest_scope is None and not prev_scope is None:
943
+ dest_scope = prev_scope
863
944
  elif dest == "~" or dest == "/" or dest == mc.self_info['name']:
864
945
  nc = None
865
946
  elif dest == "!" :
@@ -877,6 +958,12 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
877
958
  last_ack = True
878
959
  prev_contact = contact
879
960
  contact = nc
961
+ if dest_scope is None:
962
+ dest_scope = scope
963
+ if not scope is None and dest_scope != scope:
964
+ prev_scope = scope
965
+ if not dest_scope is None:
966
+ scope = await set_scope(mc, dest_scope)
880
967
 
881
968
  elif line == "to" :
882
969
  if contact is None :
@@ -956,6 +1043,12 @@ async def process_contact_chat_line(mc, contact, line):
956
1043
  if contact["type"] == 0:
957
1044
  return False
958
1045
 
1046
+ # if one element in line (most cases) strip the scope and apply it
1047
+ if not " " in line and "%" in line:
1048
+ dest_scope = line.split("%")[-1]
1049
+ line = line[:-len(dest_scope)-1]
1050
+ await set_scope (mc, dest_scope)
1051
+
959
1052
  if line.startswith(":") : # : will send a command to current recipient
960
1053
  args=["cmd", contact['adv_name'], line[1:]]
961
1054
  await process_cmds(mc, args)
@@ -966,6 +1059,23 @@ async def process_contact_chat_line(mc, contact, line):
966
1059
  await process_cmds(mc, args)
967
1060
  return True
968
1061
 
1062
+ if line.startswith("contact_name") or line.startswith("cn"):
1063
+ print(contact['adv_name'],end="")
1064
+ if " " in line:
1065
+ print(" ", end="", flush=True)
1066
+ secline = line.split(" ", 1)[1]
1067
+ await process_contact_chat_line(mc, contact, secline)
1068
+ else:
1069
+ print("")
1070
+ return True
1071
+
1072
+ if line == "contact_lastmod":
1073
+ timestamp = contact["lastmod"]
1074
+ print(f"{contact['adv_name']} updated"
1075
+ f" {datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d at %H:%M:%S')}"
1076
+ f" ({timestamp})")
1077
+ return True
1078
+
969
1079
  # commands that take contact as second arg will be sent to recipient
970
1080
  if line == "sc" or line == "share_contact" or\
971
1081
  line == "ec" or line == "export_contact" or\
@@ -974,6 +1084,7 @@ async def process_contact_chat_line(mc, contact, line):
974
1084
  line == "dp" or line == "disc_path" or\
975
1085
  line == "contact_info" or line == "ci" or\
976
1086
  line == "req_status" or line == "rs" or\
1087
+ line == "req_neighbours" or line == "rn" or\
977
1088
  line == "req_bstatus" or line == "rbs" or\
978
1089
  line == "req_telemetry" or line == "rt" or\
979
1090
  line == "req_acl" or\
@@ -1053,15 +1164,21 @@ async def process_contact_chat_line(mc, contact, line):
1053
1164
  return True
1054
1165
 
1055
1166
  # same but for commands with a parameter
1056
- if line.startswith("cmd ") or\
1057
- line.startswith("cp ") or line.startswith("change_path ") or\
1058
- line.startswith("cf ") or line.startswith("change_flags ") or\
1059
- line.startswith("req_binary ") or\
1060
- line.startswith("login ") :
1167
+ if " " in line:
1061
1168
  cmds = line.split(" ", 1)
1062
- args = [cmds[0], contact['adv_name'], cmds[1]]
1063
- await process_cmds(mc, args)
1064
- return True
1169
+ if "%" in cmds[0]:
1170
+ dest_scope = cmds[0].split("%")[-1]
1171
+ cmds[0] = cmds[0][:-len(dest_scope)-1]
1172
+ await set_scope(mc, dest_scope)
1173
+
1174
+ if cmds[0] == "cmd" or cmds[0] == "msg" or\
1175
+ cmds[0] == "cp" or cmds[0] == "change_path" or\
1176
+ cmds[0] == "cf" or cmds[0] == "change_flags" or\
1177
+ cmds[0] == "req_binary" or\
1178
+ cmds[0] == "login" :
1179
+ args = [cmds[0], contact['adv_name'], cmds[1]]
1180
+ await process_cmds(mc, args)
1181
+ return True
1065
1182
 
1066
1183
  if line == "login": # use stored password or prompt for it
1067
1184
  password_file = ""
@@ -1085,7 +1202,7 @@ async def process_contact_chat_line(mc, contact, line):
1085
1202
 
1086
1203
  if password == "":
1087
1204
  try:
1088
- sess = PromptSession("Password: ", is_password=True)
1205
+ sess = PromptSession(f"Password for {contact['adv_name']}: ", is_password=True)
1089
1206
  password = await sess.prompt_async()
1090
1207
  except EOFError:
1091
1208
  logger.info("Canceled")
@@ -1124,6 +1241,89 @@ async def process_contact_chat_line(mc, contact, line):
1124
1241
 
1125
1242
  return False
1126
1243
 
1244
+ async def apply_command_to_contacts(mc, contact_filter, line):
1245
+ upd_before = None
1246
+ upd_after = None
1247
+ contact_type = None
1248
+ min_hops = None
1249
+ max_hops = None
1250
+
1251
+ await mc.ensure_contacts()
1252
+
1253
+ filters = contact_filter.split(",")
1254
+ for f in filters:
1255
+ if f == "all":
1256
+ pass
1257
+ elif f[0] == "u": #updated
1258
+ val_str = f[2:]
1259
+ t = time.time()
1260
+ if val_str[-1] == "d": # value in days
1261
+ t = t - float(val_str[0:-1]) * 86400
1262
+ elif val_str[-1] == "h": # value in hours
1263
+ t = t - float(val_str[0:-1]) * 3600
1264
+ elif val_str[-1] == "m": # value in minutes
1265
+ t = t - float(val_str[0:-1]) * 60
1266
+ else:
1267
+ t = int(val_str)
1268
+ if f[1] == "<": #before
1269
+ upd_before = t
1270
+ elif f[1] == ">":
1271
+ upd_after = t
1272
+ else:
1273
+ logger.error(f"Time filter can only be < or >")
1274
+ return
1275
+ elif f[0] == "t": # type
1276
+ if f[1] == "=":
1277
+ contact_type = int(f[2:])
1278
+ else:
1279
+ logger.error(f"Type can only be equals to a value")
1280
+ return
1281
+ elif f[0] == "d": # direct
1282
+ min_hops=0
1283
+ elif f[0] == "f": # flood
1284
+ max_hops=-1
1285
+ elif f[0] == "h": # hop number
1286
+ if f[1] == ">":
1287
+ min_hops = int(f[2:])+1
1288
+ elif f[1] == "<":
1289
+ max_hops = int(f[2:])-1
1290
+ elif f[1] == "=":
1291
+ min_hops = int(f[2:])
1292
+ max_hops = int(f[2:])
1293
+ else:
1294
+ logger.error(f"Unknown filter {f}")
1295
+ return
1296
+
1297
+ for c in dict(mc._contacts).items():
1298
+ contact = c[1]
1299
+ if (contact_type is None or contact["type"] == contact_type) and\
1300
+ (upd_before is None or contact["lastmod"] < upd_before) and\
1301
+ (upd_after is None or contact["lastmod"] > upd_after) and\
1302
+ (min_hops is None or contact["out_path_len"] >= min_hops) and\
1303
+ (max_hops is None or contact["out_path_len"] <= max_hops):
1304
+ if await process_contact_chat_line(mc, contact, line):
1305
+ pass
1306
+
1307
+ elif line == "remove_contact":
1308
+ args = [line, contact['adv_name']]
1309
+ await process_cmds(mc, args)
1310
+
1311
+ elif line.startswith("send") or line.startswith("\"") :
1312
+ if line.startswith("send") :
1313
+ line = line[5:]
1314
+ if line.startswith("\"") :
1315
+ line = line[1:]
1316
+ await msg_ack(mc, contact, line)
1317
+
1318
+ elif contact["type"] == 2 or\
1319
+ contact["type"] == 3 or\
1320
+ contact["type"] == 4 : # repeater, room, sensor send cmd
1321
+ await process_cmds(mc, ["cmd", contact["adv_name"], line])
1322
+ # wait for a reply from cmd
1323
+ await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=7)
1324
+
1325
+ else:
1326
+ logger.error(f"Can't send {line} to {contact['adv_name']}")
1127
1327
 
1128
1328
  async def send_cmd (mc, contact, cmd) :
1129
1329
  res = await mc.commands.send_cmd(contact, cmd)
@@ -1167,7 +1367,7 @@ async def send_msg (mc, contact, msg) :
1167
1367
 
1168
1368
  async def msg_ack (mc, contact, msg) :
1169
1369
  timeout = 0 if not 'timeout' in contact else contact['timeout']
1170
- res = await mc.commands.send_msg_with_retry(contact, msg,
1370
+ res = await mc.commands.send_msg_with_retry(contact, msg,
1171
1371
  max_attempts=msg_ack.max_attempts,
1172
1372
  flood_after=msg_ack.flood_after,
1173
1373
  max_flood_attempts=msg_ack.max_flood_attempts,
@@ -1187,6 +1387,28 @@ msg_ack.max_attempts=3
1187
1387
  msg_ack.flood_after=2
1188
1388
  msg_ack.max_flood_attempts=1
1189
1389
 
1390
+ async def set_scope (mc, scope) :
1391
+ if not set_scope.has_scope:
1392
+ return None
1393
+
1394
+ if scope == "None" or scope == "0" or scope == "clear" or scope == "":
1395
+ scope = "*"
1396
+
1397
+ if set_scope.current_scope == scope:
1398
+ return scope
1399
+
1400
+ res = await mc.commands.set_flood_scope(scope)
1401
+ if res is None or res.type == EventType.ERROR:
1402
+ if not res is None and res.payload["error_code"] == 1: #unsupported
1403
+ set_scope.has_scope = False
1404
+ return None
1405
+
1406
+ set_scope.current_scope = scope
1407
+
1408
+ return scope
1409
+ set_scope.has_scope = True
1410
+ set_scope.current_scope = None
1411
+
1190
1412
  async def get_channel (mc, chan) :
1191
1413
  if not chan.isnumeric():
1192
1414
  return await get_channel_by_name(mc, chan)
@@ -1223,7 +1445,7 @@ async def set_channel (mc, chan, name, key=None):
1223
1445
  return None
1224
1446
 
1225
1447
  info = res.payload
1226
- info["channel_hash"] = sha256(info["channel_secret"]).digest()[0:1].hex()
1448
+ info["channel_hash"] = SHA256.new(info["channel_secret"]).hexdigest()[0:2]
1227
1449
  info["channel_secret"] = info["channel_secret"].hex()
1228
1450
 
1229
1451
  if hasattr(mc,'channels') :
@@ -1312,7 +1534,7 @@ async def get_channels (mc, anim=False) :
1312
1534
  if res.type == EventType.ERROR:
1313
1535
  break
1314
1536
  info = res.payload
1315
- info["channel_hash"] = sha256(info["channel_secret"]).digest()[0:1].hex()
1537
+ info["channel_hash"] = SHA256.new(info["channel_secret"]).hexdigest()[0:2]
1316
1538
  info["channel_secret"] = info["channel_secret"].hex()
1317
1539
  mc.channels.append(info)
1318
1540
  ch = ch + 1
@@ -1393,8 +1615,14 @@ async def print_disc_trace_to (mc, contact):
1393
1615
 
1394
1616
  async def next_cmd(mc, cmds, json_output=False):
1395
1617
  """ process next command """
1618
+ global ARROW_TAIL, ARROW_HEAD
1396
1619
  try :
1397
1620
  argnum = 0
1621
+
1622
+ if cmds[0].startswith("?") : # get some help
1623
+ get_help_for(cmds[0][1:], context="line")
1624
+ return cmds[argnum+1:]
1625
+
1398
1626
  if cmds[0].startswith(".") : # override json_output
1399
1627
  json_output = True
1400
1628
  cmd = cmds[0][1:]
@@ -1485,6 +1713,10 @@ async def next_cmd(mc, cmds, json_output=False):
1485
1713
  else:
1486
1714
  print("Time set")
1487
1715
 
1716
+ case "apply_to"|"at":
1717
+ argnum = 2
1718
+ await apply_command_to_contacts(mc, cmds[1], cmds[2])
1719
+
1488
1720
  case "set":
1489
1721
  argnum = 2
1490
1722
  match cmds[1]:
@@ -1517,6 +1749,10 @@ async def next_cmd(mc, cmds, json_output=False):
1517
1749
  interactive_loop.classic = (cmds[2] == "on")
1518
1750
  if json_output :
1519
1751
  print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1752
+ case "arrow_tail":
1753
+ ARROW_TAIL = cmds[2]
1754
+ case "arrow_head":
1755
+ ARROW_HEAD = cmds[2]
1520
1756
  case "color" :
1521
1757
  process_event_message.color = (cmds[2] == "on")
1522
1758
  if json_output :
@@ -1979,6 +2215,12 @@ async def next_cmd(mc, cmds, json_output=False):
1979
2215
  if res is None:
1980
2216
  print("Error setting channel")
1981
2217
 
2218
+ case "scope":
2219
+ argnum = 1
2220
+ res = await set_scope(mc, cmds[1])
2221
+ if res is None:
2222
+ print(f"Error while setting scope")
2223
+
1982
2224
  case "remove_channel":
1983
2225
  argnum = 1
1984
2226
  res = await set_channel(mc, cmds[1], "", bytes.fromhex(16*"00"))
@@ -2045,7 +2287,7 @@ async def next_cmd(mc, cmds, json_output=False):
2045
2287
  argnum = 2
2046
2288
  dest = None
2047
2289
 
2048
- if len(cmds[1]) == 12: # possibly an hex prefix
2290
+ if len(cmds[1]) == 12: # possibly an hex prefix
2049
2291
  try:
2050
2292
  dest = bytes.fromhex(cmds[1])
2051
2293
  except ValueError:
@@ -2100,7 +2342,8 @@ async def next_cmd(mc, cmds, json_output=False):
2100
2342
  if json_output:
2101
2343
  print(json.dumps(ev.payload, indent=2))
2102
2344
  else :
2103
- classic = interactive_loop.classic or not process_event_message.color
2345
+ color = process_event_message.color
2346
+ classic = interactive_loop.classic or not color
2104
2347
  print("]",end="")
2105
2348
  for t in ev.payload["path"]:
2106
2349
  if classic :
@@ -2108,18 +2351,20 @@ async def next_cmd(mc, cmds, json_output=False):
2108
2351
  else:
2109
2352
  print(f" {ANSI_INVERT}", end="")
2110
2353
  snr = t['snr']
2111
- if snr >= 10 :
2112
- print(ANSI_BGREEN, end="")
2113
- elif snr <= 0:
2114
- print(ANSI_BRED, end="")
2115
- else :
2116
- print(ANSI_BGRAY, end="")
2354
+ if color:
2355
+ if snr >= 10 :
2356
+ print(ANSI_BGREEN, end="")
2357
+ elif snr <= 0:
2358
+ print(ANSI_BRED, end="")
2359
+ else :
2360
+ print(ANSI_BGRAY, end="")
2117
2361
  print(f"{snr:.2f}",end="")
2118
2362
  if classic :
2119
2363
  print("→",end="")
2120
2364
  else :
2121
- print(f"{ANSI_NORMAL}🭬",end="")
2122
- print(ANSI_END, end="")
2365
+ print(f"{ANSI_NORMAL}{ARROW_HEAD}",end="")
2366
+ if color:
2367
+ print(ANSI_END, end="")
2123
2368
  if "hash" in t:
2124
2369
  print(f"[{t['hash']}]",end="")
2125
2370
  else:
@@ -2243,6 +2488,71 @@ async def next_cmd(mc, cmds, json_output=False):
2243
2488
  inp = inp if inp != "" else "direct"
2244
2489
  print(f"Path for {contact['adv_name']}: out {outp}, in {inp}")
2245
2490
 
2491
+ case "node_discover"|"nd" :
2492
+ argnum = 1
2493
+ prefix_only = True
2494
+
2495
+ if len(cmds) == 1:
2496
+ argnum = 0
2497
+ types = 0xFF
2498
+ else:
2499
+ try: # try to decode type as int
2500
+ types = int(cmds[1])
2501
+ except ValueError:
2502
+ if "all" in cmds[1]:
2503
+ types = 0xFF
2504
+ else :
2505
+ types = 0
2506
+ if "rep" in cmds[1] or "rpt" in cmds[1]:
2507
+ types = types | 4
2508
+ if "cli" in cmds[1] or "comp" in cmds[1]:
2509
+ types = types | 2
2510
+ if "room" in cmds[1]:
2511
+ types = types | 8
2512
+ if "sens" in cmds[1]:
2513
+ types = types | 16
2514
+
2515
+ if "full" in cmds[1]:
2516
+ prefix_only = False
2517
+
2518
+ res = await mc.commands.send_node_discover_req(types, prefix_only=prefix_only)
2519
+ if res is None or res.type == EventType.ERROR:
2520
+ print("Error sending discover request")
2521
+ else:
2522
+ exp_tag = res.payload["tag"].to_bytes(4, "little").hex()
2523
+ dn = []
2524
+ while True:
2525
+ r = await mc.wait_for_event(
2526
+ EventType.DISCOVER_RESPONSE,
2527
+ attribute_filters={"tag":exp_tag},
2528
+ timeout = 5
2529
+ )
2530
+ if r is None or r.type == EventType.ERROR:
2531
+ break
2532
+ else:
2533
+ dn.append(r.payload)
2534
+
2535
+ if json_output:
2536
+ print(json.dumps(dn))
2537
+ else:
2538
+ await mc.ensure_contacts()
2539
+ print(f"Discovered {len(dn)} nodes:")
2540
+ for n in dn:
2541
+ name = f"{n['pubkey'][0:2]} {mc.get_contact_by_key_prefix(n['pubkey'])['adv_name']}"
2542
+ if name is None:
2543
+ name = n["pubkey"][0:16]
2544
+ type = f"t:{n['node_type']}"
2545
+ if n['node_type'] == 1:
2546
+ type = "CLI"
2547
+ elif n['node_type'] == 2:
2548
+ type = "REP"
2549
+ elif n['node_type'] == 3:
2550
+ type = "ROOM"
2551
+ elif n['node_type'] == 4:
2552
+ type = "SENS"
2553
+
2554
+ print(f" {name:16} {type:>4} SNR: {n['SNR_in']:6,.2f}->{n['SNR']:6,.2f} RSSI: ->{n['RSSI']:4}")
2555
+
2246
2556
  case "req_btelemetry"|"rbt" :
2247
2557
  argnum = 1
2248
2558
  await mc.ensure_contacts()
@@ -2326,6 +2636,31 @@ async def next_cmd(mc, cmds, json_output=False):
2326
2636
  name = f"{ct['adv_name']:<20} [{e['key']}]"
2327
2637
  print(f"{name:{' '}<35}: {e['perm']:02x}")
2328
2638
 
2639
+ case "req_neighbours"|"rn" :
2640
+ argnum = 1
2641
+ await mc.ensure_contacts()
2642
+ contact = mc.get_contact_by_name(cmds[1])
2643
+ timeout = 0 if not "timeout" in contact else contact["timeout"]
2644
+ res = await mc.commands.fetch_all_neighbours(contact, timeout=timeout)
2645
+ if res is None :
2646
+ if json_output :
2647
+ print(json.dumps({"error" : "Getting data"}))
2648
+ else:
2649
+ print("Error getting data")
2650
+ else :
2651
+ if json_output:
2652
+ print(json.dumps(res, indent=4))
2653
+ else:
2654
+ print(f"Got {res['results_count']} neighbours out of {res['neighbours_count']} from {contact['adv_name']}:")
2655
+ for n in res['neighbours']:
2656
+ ct = mc.get_contact_by_key_prefix(n["pubkey"])
2657
+ if ct :
2658
+ name = f"[{n['pubkey'][0:8]}] {ct['adv_name']}"
2659
+ else:
2660
+ name = f"[{n['pubkey']}]"
2661
+
2662
+ print(f" {name:30} last viewed {n['secs_ago']} sec ago at {n['snr']} ")
2663
+
2329
2664
  case "req_binary" :
2330
2665
  argnum = 2
2331
2666
  await mc.ensure_contacts()
@@ -2718,7 +3053,7 @@ async def next_cmd(mc, cmds, json_output=False):
2718
3053
  await mc.ensure_contacts()
2719
3054
  contact = mc.get_contact_by_name(cmds[0])
2720
3055
  if contact is None:
2721
- logger.error(f"Unknown command : {cmd}. {cmds} not executed ...")
3056
+ logger.error(f"Unknown command : {cmd}, {cmds} not executed ...")
2722
3057
  return None
2723
3058
 
2724
3059
  await interactive_loop(mc, to=contact)
@@ -2762,7 +3097,8 @@ def version():
2762
3097
  print (f"meshcore-cli: command line interface to MeshCore companion radios {VERSION}")
2763
3098
 
2764
3099
  def command_help():
2765
- print(""" General commands
3100
+ print(""" ?<cmd> may give you some more help about cmd
3101
+ General commands
2766
3102
  chat : enter the chat (interactive) mode
2767
3103
  chat_to <ct> : enter chat with contact to
2768
3104
  script <filename> : execute commands in filename
@@ -2773,6 +3109,7 @@ def command_help():
2773
3109
  reboot : reboots node
2774
3110
  sleep <secs> : sleeps for a given amount of secs s
2775
3111
  wait_key : wait until user presses <Enter> wk
3112
+ apply_to <scope> <cmds>: sends cmds to contacts matching scope at
2776
3113
  Messenging
2777
3114
  msg <name> <msg> : send message to node by name m {
2778
3115
  wait_ack : wait an ack wa }
@@ -2794,6 +3131,7 @@ def command_help():
2794
3131
  time <epoch> : sets time to given epoch
2795
3132
  clock : get current time
2796
3133
  clock sync : sync device clock st
3134
+ node_discover <filter> : discovers nodes based on their type nd
2797
3135
  Contacts
2798
3136
  contacts / list : gets contact list lc
2799
3137
  reload_contacts : force reloading all contacts rc
@@ -2820,6 +3158,7 @@ def command_help():
2820
3158
  cmd <name> <cmd> : sends a command to a repeater (no ack) c [
2821
3159
  wmt8 : wait for a msg (reply) with a timeout ]
2822
3160
  req_status <name> : requests status from a node rs
3161
+ req_neighbours <name> : requests for neighbours in binary form rn
2823
3162
  trace <path> : run a trace, path is comma separated""")
2824
3163
 
2825
3164
  def usage () :
@@ -2835,6 +3174,7 @@ def usage () :
2835
3174
  -D : debug
2836
3175
  -S : scan for devices and show a selector
2837
3176
  -l : list available ble/serial devices and exit
3177
+ -c <on/off> : disables most of color output if off
2838
3178
  -T <timeout> : timeout for the ble scan (-S and -l) default 2s
2839
3179
  -a <address> : specifies device address (can be a name)
2840
3180
  -d <name> : filter meshcore devices with name or address
@@ -2847,6 +3187,43 @@ def usage () :
2847
3187
  Available Commands and shorcuts (can be chained) :""")
2848
3188
  command_help()
2849
3189
 
3190
+ def get_help_for (cmdname, context="line") :
3191
+ if cmdname == "apply_to" or cmdname == "at" :
3192
+ print("""apply_to <scope> <cmd> : applies cmd to contacts matching scope
3193
+ Scope acts like a filter with comma separated fields :
3194
+ - u, matches modification time < or > than a timestamp
3195
+ (can also be days hours or minutes ago if followed by d,h or m)
3196
+ - t, matches the type (1: client, 2: repeater, 3: room, 4: sensor)
3197
+ - h, matches number of hops
3198
+ - d, direct, similar to h>-1
3199
+ - f, flood, similar to h<0 or h=-1
3200
+
3201
+ Note: Some commands like contact_name (aka cn), reset_path (aka rp), forget_password (aka fp) can be chained.
3202
+
3203
+ Examples:
3204
+ # removes all clients that have not been updated in last 2 days
3205
+ at u<2d,t=1 remove_contact
3206
+ # gives traces to repeaters that have been updated in the last 24h and are direct
3207
+ at t=2,u>1d,d cn trace
3208
+ # tries to do flood login to all repeaters
3209
+ at t=2 rp login
3210
+ """)
3211
+
3212
+ if cmdname == "node_discover" or cmdname == "nd" :
3213
+ print("""node_discover <filter> : discovers 0-hop nodes and displays signal info
3214
+
3215
+ filter can be "all" for all types or nodes or a comma separated list consisting of :
3216
+ - cli or comp for companions
3217
+ - rep for repeaters
3218
+ - sens for sensors
3219
+ - room for chat rooms
3220
+
3221
+ nd can be used with no filter parameter ... !!! BEWARE WITH CHAINING !!!
3222
+ """)
3223
+
3224
+ else:
3225
+ print(f"Sorry, no help yet for {cmdname}")
3226
+
2850
3227
  async def main(argv):
2851
3228
  """ Do the job """
2852
3229
  json_output = JSON
@@ -2865,9 +3242,12 @@ async def main(argv):
2865
3242
  with open(MCCLI_ADDRESS, encoding="utf-8") as f :
2866
3243
  address = f.readline().strip()
2867
3244
 
2868
- opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:P")
3245
+ opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:Pc:")
2869
3246
  for opt, arg in opts :
2870
3247
  match opt:
3248
+ case "-c" :
3249
+ if arg == "off":
3250
+ process_event_message.color = False
2871
3251
  case "-d" : # name specified on cmdline
2872
3252
  address = arg
2873
3253
  case "-a" : # address specified on cmdline
File without changes
File without changes
File without changes
File without changes
File without changes