meshcore-cli 1.2.5__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.5
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,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.23
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.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", "pycryptodome" ]
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,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.11"
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,
@@ -569,6 +589,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
569
589
  "neighbors" : None,
570
590
  "req_acl":None,
571
591
  "setperm":contact_list,
592
+ "region" : {"get":None, "allowf": None, "denyf": None, "put": None, "remove": None, "save": None, "home": None},
572
593
  "gps" : {"on":None,"off":None,"sync":None,"setloc":None,
573
594
  "advert" : {"none": None, "share": None, "prefs": None},
574
595
  },
@@ -652,7 +673,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
652
673
  slash_root_completion_list = {}
653
674
  for k,v in root_completion_list.items():
654
675
  slash_root_completion_list["/"+k]=v
655
-
676
+
656
677
  completion_list.update(slash_root_completion_list)
657
678
 
658
679
  slash_contacts_completion_list = {}
@@ -697,6 +718,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
697
718
  contact = to
698
719
  prev_contact = None
699
720
 
721
+ scope = await set_scope(mc, "*")
722
+ prev_scope = scope
723
+
700
724
  await get_contacts(mc, anim=True)
701
725
  await get_channels(mc, anim=True)
702
726
  await subscribe_to_msgs(mc, above=True)
@@ -735,6 +759,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
735
759
 
736
760
  last_ack = True
737
761
  while True:
762
+ # reset scope (if changed)
763
+ scope = await set_scope(mc, scope)
764
+
738
765
  color = process_event_message.color
739
766
  classic = interactive_loop.classic or not color
740
767
  print_name = interactive_loop.print_name
@@ -744,14 +771,17 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
744
771
  else:
745
772
  prompt = f"{ANSI_INVERT}"
746
773
 
747
- # some possible symbols for prompts 🭬🬛🬗🭬🬛🬃🬗🭬🬛🬃🬗🬏🭀🭋🭨🮋
748
774
  if print_name or contact is None :
749
- prompt = prompt + f"{ANSI_BGRAY}"
775
+ if color:
776
+ prompt = prompt + f"{ANSI_BGRAY}"
750
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}"
751
781
  if classic :
752
- prompt = prompt + " > "
782
+ prompt = prompt + "> "
753
783
  else :
754
- prompt = prompt + f"{ANSI_NORMAL}🭬{ANSI_INVERT}"
784
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}{ANSI_INVERT}"
755
785
 
756
786
  if not contact is None :
757
787
  if not last_ack:
@@ -772,13 +802,24 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
772
802
  prompt = prompt + f"{ANSI_INVERT}"
773
803
 
774
804
  if print_name and not classic :
775
- prompt = prompt + f"{ANSI_NORMAL}🭨{ANSI_INVERT}"
805
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_TAIL}{ANSI_INVERT}"
776
806
 
777
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
+
778
819
  if classic :
779
- prompt = prompt + f"{ANSI_NORMAL} > "
820
+ prompt = prompt + f"{ANSI_NORMAL}> "
780
821
  else:
781
- prompt = prompt + f"{ANSI_NORMAL}🭬"
822
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}"
782
823
 
783
824
  prompt = prompt + f"{ANSI_END}"
784
825
 
@@ -799,9 +840,14 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
799
840
  completer=completer,
800
841
  key_bindings=bindings)
801
842
 
843
+ line = line.strip()
844
+
802
845
  if line == "" : # blank line
803
846
  pass
804
847
 
848
+ elif line.startswith("?") :
849
+ get_help_for(line[1:], context="chat")
850
+
805
851
  # raw meshcli command as on command line
806
852
  elif line.startswith("$") :
807
853
  try :
@@ -810,18 +856,41 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
810
856
  except ValueError:
811
857
  logger.error("Error parsing line {line[1:]}")
812
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")
875
+
813
876
  elif line.startswith("/") :
814
877
  path = line.split(" ", 1)[0]
815
878
  if path.count("/") == 1:
816
879
  args = line[1:].split(" ")
817
- tct = mc.get_contact_by_name(args[0])
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)
818
887
  if len(args)>1 and not tct is None: # a contact, send a message
819
888
  if tct["type"] == 1 or tct["type"] == 3: # client or room
820
889
  last_ack = await msg_ack(mc, tct, line.split(" ", 1)[1])
821
890
  else:
822
891
  print("Can only send msg to chan, client or room")
823
892
  else :
824
- ch = await get_channel_by_name(mc, args[0])
893
+ ch = await get_channel_by_name(mc, dest)
825
894
  if len(args)>1 and not ch is None: # a channel, send message
826
895
  await send_chan_msg(mc, ch["channel_idx"], line.split(" ", 1)[1])
827
896
  else :
@@ -832,6 +901,11 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
832
901
  else:
833
902
  cmdline = line[1:].split("/",1)[1]
834
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)
835
909
  tct = mc.get_contact_by_name(contact_name)
836
910
  if tct is None:
837
911
  print(f"{contact_name} is not a contact")
@@ -847,6 +921,10 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
847
921
  dest = line[3:]
848
922
  if dest.startswith("\"") or dest.startswith("\'") : # if name starts with a quote
849
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]
850
928
  nc = mc.get_contact_by_name(dest)
851
929
  if nc is None:
852
930
  if dest == "public" :
@@ -860,6 +938,8 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
860
938
  nc["adv_name"] = mc.channels[dest]["channel_name"]
861
939
  elif dest == ".." : # previous recipient
862
940
  nc = prev_contact
941
+ if dest_scope is None and not prev_scope is None:
942
+ dest_scope = prev_scope
863
943
  elif dest == "~" or dest == "/" or dest == mc.self_info['name']:
864
944
  nc = None
865
945
  elif dest == "!" :
@@ -877,6 +957,12 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
877
957
  last_ack = True
878
958
  prev_contact = contact
879
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)
880
966
 
881
967
  elif line == "to" :
882
968
  if contact is None :
@@ -956,6 +1042,12 @@ async def process_contact_chat_line(mc, contact, line):
956
1042
  if contact["type"] == 0:
957
1043
  return False
958
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
+
959
1051
  if line.startswith(":") : # : will send a command to current recipient
960
1052
  args=["cmd", contact['adv_name'], line[1:]]
961
1053
  await process_cmds(mc, args)
@@ -966,6 +1058,23 @@ async def process_contact_chat_line(mc, contact, line):
966
1058
  await process_cmds(mc, args)
967
1059
  return True
968
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
+
969
1078
  # commands that take contact as second arg will be sent to recipient
970
1079
  if line == "sc" or line == "share_contact" or\
971
1080
  line == "ec" or line == "export_contact" or\
@@ -1053,15 +1162,21 @@ async def process_contact_chat_line(mc, contact, line):
1053
1162
  return True
1054
1163
 
1055
1164
  # 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 ") :
1165
+ if " " in line:
1061
1166
  cmds = line.split(" ", 1)
1062
- args = [cmds[0], contact['adv_name'], cmds[1]]
1063
- await process_cmds(mc, args)
1064
- 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
1065
1180
 
1066
1181
  if line == "login": # use stored password or prompt for it
1067
1182
  password_file = ""
@@ -1085,7 +1200,7 @@ async def process_contact_chat_line(mc, contact, line):
1085
1200
 
1086
1201
  if password == "":
1087
1202
  try:
1088
- sess = PromptSession("Password: ", is_password=True)
1203
+ sess = PromptSession(f"Password for {contact['adv_name']}: ", is_password=True)
1089
1204
  password = await sess.prompt_async()
1090
1205
  except EOFError:
1091
1206
  logger.info("Canceled")
@@ -1124,6 +1239,89 @@ async def process_contact_chat_line(mc, contact, line):
1124
1239
 
1125
1240
  return False
1126
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']}")
1127
1325
 
1128
1326
  async def send_cmd (mc, contact, cmd) :
1129
1327
  res = await mc.commands.send_cmd(contact, cmd)
@@ -1167,7 +1365,7 @@ async def send_msg (mc, contact, msg) :
1167
1365
 
1168
1366
  async def msg_ack (mc, contact, msg) :
1169
1367
  timeout = 0 if not 'timeout' in contact else contact['timeout']
1170
- res = await mc.commands.send_msg_with_retry(contact, msg,
1368
+ res = await mc.commands.send_msg_with_retry(contact, msg,
1171
1369
  max_attempts=msg_ack.max_attempts,
1172
1370
  flood_after=msg_ack.flood_after,
1173
1371
  max_flood_attempts=msg_ack.max_flood_attempts,
@@ -1187,6 +1385,28 @@ msg_ack.max_attempts=3
1187
1385
  msg_ack.flood_after=2
1188
1386
  msg_ack.max_flood_attempts=1
1189
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
+
1190
1410
  async def get_channel (mc, chan) :
1191
1411
  if not chan.isnumeric():
1192
1412
  return await get_channel_by_name(mc, chan)
@@ -1223,7 +1443,7 @@ async def set_channel (mc, chan, name, key=None):
1223
1443
  return None
1224
1444
 
1225
1445
  info = res.payload
1226
- info["channel_hash"] = sha256(info["channel_secret"]).digest()[0:1].hex()
1446
+ info["channel_hash"] = SHA256.new(info["channel_secret"]).hexdigest()[0:2]
1227
1447
  info["channel_secret"] = info["channel_secret"].hex()
1228
1448
 
1229
1449
  if hasattr(mc,'channels') :
@@ -1312,7 +1532,7 @@ async def get_channels (mc, anim=False) :
1312
1532
  if res.type == EventType.ERROR:
1313
1533
  break
1314
1534
  info = res.payload
1315
- info["channel_hash"] = sha256(info["channel_secret"]).digest()[0:1].hex()
1535
+ info["channel_hash"] = SHA256.new(info["channel_secret"]).hexdigest()[0:2]
1316
1536
  info["channel_secret"] = info["channel_secret"].hex()
1317
1537
  mc.channels.append(info)
1318
1538
  ch = ch + 1
@@ -1393,8 +1613,14 @@ async def print_disc_trace_to (mc, contact):
1393
1613
 
1394
1614
  async def next_cmd(mc, cmds, json_output=False):
1395
1615
  """ process next command """
1616
+ global ARROW_TAIL, ARROW_HEAD
1396
1617
  try :
1397
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
+
1398
1624
  if cmds[0].startswith(".") : # override json_output
1399
1625
  json_output = True
1400
1626
  cmd = cmds[0][1:]
@@ -1485,6 +1711,10 @@ async def next_cmd(mc, cmds, json_output=False):
1485
1711
  else:
1486
1712
  print("Time set")
1487
1713
 
1714
+ case "apply_to"|"at":
1715
+ argnum = 2
1716
+ await apply_command_to_contacts(mc, cmds[1], cmds[2])
1717
+
1488
1718
  case "set":
1489
1719
  argnum = 2
1490
1720
  match cmds[1]:
@@ -1517,6 +1747,10 @@ async def next_cmd(mc, cmds, json_output=False):
1517
1747
  interactive_loop.classic = (cmds[2] == "on")
1518
1748
  if json_output :
1519
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]
1520
1754
  case "color" :
1521
1755
  process_event_message.color = (cmds[2] == "on")
1522
1756
  if json_output :
@@ -1979,6 +2213,12 @@ async def next_cmd(mc, cmds, json_output=False):
1979
2213
  if res is None:
1980
2214
  print("Error setting channel")
1981
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
+
1982
2222
  case "remove_channel":
1983
2223
  argnum = 1
1984
2224
  res = await set_channel(mc, cmds[1], "", bytes.fromhex(16*"00"))
@@ -2045,7 +2285,7 @@ async def next_cmd(mc, cmds, json_output=False):
2045
2285
  argnum = 2
2046
2286
  dest = None
2047
2287
 
2048
- if len(cmds[1]) == 12: # possibly an hex prefix
2288
+ if len(cmds[1]) == 12: # possibly an hex prefix
2049
2289
  try:
2050
2290
  dest = bytes.fromhex(cmds[1])
2051
2291
  except ValueError:
@@ -2100,7 +2340,8 @@ async def next_cmd(mc, cmds, json_output=False):
2100
2340
  if json_output:
2101
2341
  print(json.dumps(ev.payload, indent=2))
2102
2342
  else :
2103
- classic = interactive_loop.classic or not process_event_message.color
2343
+ color = process_event_message.color
2344
+ classic = interactive_loop.classic or not color
2104
2345
  print("]",end="")
2105
2346
  for t in ev.payload["path"]:
2106
2347
  if classic :
@@ -2108,18 +2349,20 @@ async def next_cmd(mc, cmds, json_output=False):
2108
2349
  else:
2109
2350
  print(f" {ANSI_INVERT}", end="")
2110
2351
  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="")
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="")
2117
2359
  print(f"{snr:.2f}",end="")
2118
2360
  if classic :
2119
2361
  print("→",end="")
2120
2362
  else :
2121
- print(f"{ANSI_NORMAL}🭬",end="")
2122
- print(ANSI_END, end="")
2363
+ print(f"{ANSI_NORMAL}{ARROW_HEAD}",end="")
2364
+ if color:
2365
+ print(ANSI_END, end="")
2123
2366
  if "hash" in t:
2124
2367
  print(f"[{t['hash']}]",end="")
2125
2368
  else:
@@ -2243,6 +2486,71 @@ async def next_cmd(mc, cmds, json_output=False):
2243
2486
  inp = inp if inp != "" else "direct"
2244
2487
  print(f"Path for {contact['adv_name']}: out {outp}, in {inp}")
2245
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
+
2246
2554
  case "req_btelemetry"|"rbt" :
2247
2555
  argnum = 1
2248
2556
  await mc.ensure_contacts()
@@ -2718,7 +3026,7 @@ async def next_cmd(mc, cmds, json_output=False):
2718
3026
  await mc.ensure_contacts()
2719
3027
  contact = mc.get_contact_by_name(cmds[0])
2720
3028
  if contact is None:
2721
- logger.error(f"Unknown command : {cmd}. {cmds} not executed ...")
3029
+ logger.error(f"Unknown command : {cmd}, {cmds} not executed ...")
2722
3030
  return None
2723
3031
 
2724
3032
  await interactive_loop(mc, to=contact)
@@ -2762,7 +3070,8 @@ def version():
2762
3070
  print (f"meshcore-cli: command line interface to MeshCore companion radios {VERSION}")
2763
3071
 
2764
3072
  def command_help():
2765
- print(""" General commands
3073
+ print(""" ?<cmd> may give you some more help about cmd
3074
+ General commands
2766
3075
  chat : enter the chat (interactive) mode
2767
3076
  chat_to <ct> : enter chat with contact to
2768
3077
  script <filename> : execute commands in filename
@@ -2773,6 +3082,7 @@ def command_help():
2773
3082
  reboot : reboots node
2774
3083
  sleep <secs> : sleeps for a given amount of secs s
2775
3084
  wait_key : wait until user presses <Enter> wk
3085
+ apply_to <scope> <cmds>: sends cmds to contacts matching scope at
2776
3086
  Messenging
2777
3087
  msg <name> <msg> : send message to node by name m {
2778
3088
  wait_ack : wait an ack wa }
@@ -2794,6 +3104,7 @@ def command_help():
2794
3104
  time <epoch> : sets time to given epoch
2795
3105
  clock : get current time
2796
3106
  clock sync : sync device clock st
3107
+ node_discover <filter> : discovers nodes based on their type nd
2797
3108
  Contacts
2798
3109
  contacts / list : gets contact list lc
2799
3110
  reload_contacts : force reloading all contacts rc
@@ -2835,6 +3146,7 @@ def usage () :
2835
3146
  -D : debug
2836
3147
  -S : scan for devices and show a selector
2837
3148
  -l : list available ble/serial devices and exit
3149
+ -c <on/off> : disables most of color output if off
2838
3150
  -T <timeout> : timeout for the ble scan (-S and -l) default 2s
2839
3151
  -a <address> : specifies device address (can be a name)
2840
3152
  -d <name> : filter meshcore devices with name or address
@@ -2847,6 +3159,43 @@ def usage () :
2847
3159
  Available Commands and shorcuts (can be chained) :""")
2848
3160
  command_help()
2849
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
+
2850
3199
  async def main(argv):
2851
3200
  """ Do the job """
2852
3201
  json_output = JSON
@@ -2865,9 +3214,12 @@ async def main(argv):
2865
3214
  with open(MCCLI_ADDRESS, encoding="utf-8") as f :
2866
3215
  address = f.readline().strip()
2867
3216
 
2868
- 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:")
2869
3218
  for opt, arg in opts :
2870
3219
  match opt:
3220
+ case "-c" :
3221
+ if arg == "off":
3222
+ process_event_message.color = False
2871
3223
  case "-d" : # name specified on cmdline
2872
3224
  address = arg
2873
3225
  case "-a" : # address specified on cmdline
File without changes
File without changes
File without changes
File without changes
File without changes