meshcore-cli 1.2.5__tar.gz → 1.2.6__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.6
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.20
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.6"
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.20", "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
8
8
  import time, datetime
9
9
  import getopt, json, shlex, re
10
10
  import logging
@@ -33,7 +33,7 @@ import re
33
33
  from meshcore import MeshCore, EventType, logger
34
34
 
35
35
  # Version
36
- VERSION = "v1.2.5"
36
+ VERSION = "v1.2.6"
37
37
 
38
38
  # default ble address is stored in a config file
39
39
  MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/"
@@ -208,15 +208,19 @@ async def handle_log_rx(event):
208
208
  return
209
209
 
210
210
  pkt = bytes().fromhex(event.payload["payload"])
211
+ pbuf = io.BytesIO(pkt)
212
+ header = pbuf.read(1)[0]
213
+
214
+ if header & ~1 == 0x14: # flood msg / channel
215
+ if handle_log_rx.channel_echoes:
216
+ if header & 1 == 0: # has transport code
217
+ pbuf.read(4) # discard transport code
218
+ path_len = pbuf.read(1)[0]
219
+ path = pbuf.read(path_len).hex()
220
+ chan_hash = pbuf.read(1).hex()
221
+ cipher_mac = pbuf.read(2)
222
+ msg = pbuf.read() # until the end of buffer
211
223
 
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
224
  channel = None
221
225
  for c in await get_channels(mc):
222
226
  if c["channel_hash"] == chan_hash : # validate against MAC
@@ -226,6 +230,8 @@ async def handle_log_rx(event):
226
230
  channel = c
227
231
  break
228
232
 
233
+ chan_name = ""
234
+
229
235
  if channel is None :
230
236
  if handle_log_rx.echo_unk_chans:
231
237
  chan_name = chan_hash
@@ -235,7 +241,7 @@ async def handle_log_rx(event):
235
241
  aes_key = bytes.fromhex(channel["channel_secret"])
236
242
  cipher = AES.new(aes_key, AES.MODE_ECB)
237
243
  message = cipher.decrypt(msg)[5:].decode("utf-8").strip("\x00")
238
-
244
+
239
245
  if chan_name != "" :
240
246
  width = os.get_terminal_size().columns
241
247
  cars = width - 13 - 2 * path_len - len(chan_name) - 1
@@ -245,7 +251,7 @@ async def handle_log_rx(event):
245
251
  print_above(txt)
246
252
  else:
247
253
  print(txt)
248
-
254
+
249
255
  handle_log_rx.json_log_rx = False
250
256
  handle_log_rx.channel_echoes = False
251
257
  handle_log_rx.mc = None
@@ -322,7 +328,7 @@ async def log_message(mc, msg):
322
328
  if msg["type"] == "PRIV" :
323
329
  ct = mc.get_contact_by_key_prefix(msg['pubkey_prefix'])
324
330
  if ct is None:
325
- msg["name"] = data["pubkey_prefix"]
331
+ msg["name"] = msg["pubkey_prefix"]
326
332
  else:
327
333
  msg["name"] = ct["adv_name"]
328
334
  elif msg["type"] == "CHAN" :
@@ -366,7 +372,7 @@ async def subscribe_to_msgs(mc, json_output=False, above=False):
366
372
  class MyNestedCompleter(NestedCompleter):
367
373
  def get_completions( self, document, complete_event):
368
374
  txt = document.text_before_cursor.lstrip()
369
- if not " " in txt:
375
+ if not " " in txt:
370
376
  if txt != "" and txt[0] == "/" and txt.count("/") == 1:
371
377
  opts = []
372
378
  for k in self.options.keys():
@@ -465,6 +471,8 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
465
471
  "set_channel": None,
466
472
  "get_channels": None,
467
473
  "remove_channel": None,
474
+ "apply_to": None,
475
+ "at": None,
468
476
  "set" : {
469
477
  "name" : None,
470
478
  "pin" : None,
@@ -531,6 +539,8 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
531
539
 
532
540
  contact_completion_list = {
533
541
  "contact_info": None,
542
+ "contact_name": None,
543
+ "contact_lastmod": None,
534
544
  "export_contact" : None,
535
545
  "share_contact" : None,
536
546
  "upload_contact" : None,
@@ -652,7 +662,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
652
662
  slash_root_completion_list = {}
653
663
  for k,v in root_completion_list.items():
654
664
  slash_root_completion_list["/"+k]=v
655
-
665
+
656
666
  completion_list.update(slash_root_completion_list)
657
667
 
658
668
  slash_contacts_completion_list = {}
@@ -802,6 +812,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
802
812
  if line == "" : # blank line
803
813
  pass
804
814
 
815
+ elif line.startswith("?") :
816
+ get_help_for(line[1:], context="chat")
817
+
805
818
  # raw meshcli command as on command line
806
819
  elif line.startswith("$") :
807
820
  try :
@@ -905,6 +918,13 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
905
918
  if last_ack == False :
906
919
  contact = ln
907
920
 
921
+ elif contact is None and\
922
+ (line.startswith("apply_to ") or line.startswith("at ")):
923
+ try:
924
+ await apply_command_to_contacts(mc, line.split(" ",2)[1], line.split(" ",2)[2])
925
+ except IndexError:
926
+ logger.error(f"Error with apply_to command parameters")
927
+
908
928
  # commands are passed through if at root
909
929
  elif contact is None or line.startswith(".") :
910
930
  try:
@@ -966,6 +986,23 @@ async def process_contact_chat_line(mc, contact, line):
966
986
  await process_cmds(mc, args)
967
987
  return True
968
988
 
989
+ if line.startswith("contact_name") or line.startswith("cn"):
990
+ print(contact['adv_name'],end="")
991
+ if " " in line:
992
+ print(" ", end="", flush=True)
993
+ secline = line.split(" ", 1)[1]
994
+ await process_contact_chat_line(mc, contact, secline)
995
+ else:
996
+ print("")
997
+ return True
998
+
999
+ if line == "contact_lastmod":
1000
+ timestamp = contact["lastmod"]
1001
+ print(f"{contact['adv_name']} updated"
1002
+ f" {datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d at %H:%M:%S')}"
1003
+ f" ({timestamp})")
1004
+ return True
1005
+
969
1006
  # commands that take contact as second arg will be sent to recipient
970
1007
  if line == "sc" or line == "share_contact" or\
971
1008
  line == "ec" or line == "export_contact" or\
@@ -1053,7 +1090,7 @@ async def process_contact_chat_line(mc, contact, line):
1053
1090
  return True
1054
1091
 
1055
1092
  # same but for commands with a parameter
1056
- if line.startswith("cmd ") or\
1093
+ if line.startswith("cmd ") or line.startswith("msg ") or\
1057
1094
  line.startswith("cp ") or line.startswith("change_path ") or\
1058
1095
  line.startswith("cf ") or line.startswith("change_flags ") or\
1059
1096
  line.startswith("req_binary ") or\
@@ -1085,7 +1122,7 @@ async def process_contact_chat_line(mc, contact, line):
1085
1122
 
1086
1123
  if password == "":
1087
1124
  try:
1088
- sess = PromptSession("Password: ", is_password=True)
1125
+ sess = PromptSession(f"Password for {contact['adv_name']}: ", is_password=True)
1089
1126
  password = await sess.prompt_async()
1090
1127
  except EOFError:
1091
1128
  logger.info("Canceled")
@@ -1124,6 +1161,89 @@ async def process_contact_chat_line(mc, contact, line):
1124
1161
 
1125
1162
  return False
1126
1163
 
1164
+ async def apply_command_to_contacts(mc, contact_filter, line):
1165
+ upd_before = None
1166
+ upd_after = None
1167
+ contact_type = None
1168
+ min_hops = None
1169
+ max_hops = None
1170
+
1171
+ await mc.ensure_contacts()
1172
+
1173
+ filters = contact_filter.split(",")
1174
+ for f in filters:
1175
+ if f == "all":
1176
+ pass
1177
+ elif f[0] == "u": #updated
1178
+ val_str = f[2:]
1179
+ t = time.time()
1180
+ if val_str[-1] == "d": # value in days
1181
+ t = t - float(val_str[0:-1]) * 86400
1182
+ elif val_str[-1] == "h": # value in hours
1183
+ t = t - float(val_str[0:-1]) * 3600
1184
+ elif val_str[-1] == "m": # value in minutes
1185
+ t = t - float(val_str[0:-1]) * 60
1186
+ else:
1187
+ t = int(val_str)
1188
+ if f[1] == "<": #before
1189
+ upd_before = t
1190
+ elif f[1] == ">":
1191
+ upd_after = t
1192
+ else:
1193
+ logger.error(f"Time filter can only be < or >")
1194
+ return
1195
+ elif f[0] == "t": # type
1196
+ if f[1] == "=":
1197
+ contact_type = int(f[2:])
1198
+ else:
1199
+ logger.error(f"Type can only be equals to a value")
1200
+ return
1201
+ elif f[0] == "d": # direct
1202
+ min_hops=0
1203
+ elif f[0] == "f": # flood
1204
+ max_hops=-1
1205
+ elif f[0] == "h": # hop number
1206
+ if f[1] == ">":
1207
+ min_hops = int(f[2:])+1
1208
+ elif f[1] == "<":
1209
+ max_hops = int(f[2:])-1
1210
+ elif f[1] == "=":
1211
+ min_hops = int(f[2:])
1212
+ max_hops = int(f[2:])
1213
+ else:
1214
+ logger.error(f"Unknown filter {f}")
1215
+ return
1216
+
1217
+ for c in dict(mc._contacts).items():
1218
+ contact = c[1]
1219
+ if (contact_type is None or contact["type"] == contact_type) and\
1220
+ (upd_before is None or contact["lastmod"] < upd_before) and\
1221
+ (upd_after is None or contact["lastmod"] > upd_after) and\
1222
+ (min_hops is None or contact["out_path_len"] >= min_hops) and\
1223
+ (max_hops is None or contact["out_path_len"] <= max_hops):
1224
+ if await process_contact_chat_line(mc, contact, line):
1225
+ pass
1226
+
1227
+ elif line == "remove_contact":
1228
+ args = [line, contact['adv_name']]
1229
+ await process_cmds(mc, args)
1230
+
1231
+ elif line.startswith("send") or line.startswith("\"") :
1232
+ if line.startswith("send") :
1233
+ line = line[5:]
1234
+ if line.startswith("\"") :
1235
+ line = line[1:]
1236
+ await msg_ack(mc, contact, line)
1237
+
1238
+ elif contact["type"] == 2 or\
1239
+ contact["type"] == 3 or\
1240
+ contact["type"] == 4 : # repeater, room, sensor send cmd
1241
+ await process_cmds(mc, ["cmd", contact["adv_name"], line])
1242
+ # wait for a reply from cmd
1243
+ await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=7)
1244
+
1245
+ else:
1246
+ logger.error(f"Can't send {line} to {contact['adv_name']}")
1127
1247
 
1128
1248
  async def send_cmd (mc, contact, cmd) :
1129
1249
  res = await mc.commands.send_cmd(contact, cmd)
@@ -1395,6 +1515,11 @@ async def next_cmd(mc, cmds, json_output=False):
1395
1515
  """ process next command """
1396
1516
  try :
1397
1517
  argnum = 0
1518
+
1519
+ if cmds[0].startswith("?") : # get some help
1520
+ get_help_for(cmds[0][1:], context="line")
1521
+ return cmds[argnum+1:]
1522
+
1398
1523
  if cmds[0].startswith(".") : # override json_output
1399
1524
  json_output = True
1400
1525
  cmd = cmds[0][1:]
@@ -1485,6 +1610,10 @@ async def next_cmd(mc, cmds, json_output=False):
1485
1610
  else:
1486
1611
  print("Time set")
1487
1612
 
1613
+ case "apply_to"|"at":
1614
+ argnum = 2
1615
+ await apply_command_to_contacts(mc, cmds[1], cmds[2])
1616
+
1488
1617
  case "set":
1489
1618
  argnum = 2
1490
1619
  match cmds[1]:
@@ -1979,6 +2108,12 @@ async def next_cmd(mc, cmds, json_output=False):
1979
2108
  if res is None:
1980
2109
  print("Error setting channel")
1981
2110
 
2111
+ case "scope":
2112
+ argnum = 1
2113
+ res = await mc.commands.set_flood_scope(cmds[1])
2114
+ if res is None or res.type == EventType.ERROR:
2115
+ print(f"Error while setting scope")
2116
+
1982
2117
  case "remove_channel":
1983
2118
  argnum = 1
1984
2119
  res = await set_channel(mc, cmds[1], "", bytes.fromhex(16*"00"))
@@ -2718,7 +2853,7 @@ async def next_cmd(mc, cmds, json_output=False):
2718
2853
  await mc.ensure_contacts()
2719
2854
  contact = mc.get_contact_by_name(cmds[0])
2720
2855
  if contact is None:
2721
- logger.error(f"Unknown command : {cmd}. {cmds} not executed ...")
2856
+ logger.error(f"Unknown command : {cmd}, {cmds} not executed ...")
2722
2857
  return None
2723
2858
 
2724
2859
  await interactive_loop(mc, to=contact)
@@ -2762,7 +2897,8 @@ def version():
2762
2897
  print (f"meshcore-cli: command line interface to MeshCore companion radios {VERSION}")
2763
2898
 
2764
2899
  def command_help():
2765
- print(""" General commands
2900
+ print(""" ?<cmd> may give you some more help about cmd
2901
+ General commands
2766
2902
  chat : enter the chat (interactive) mode
2767
2903
  chat_to <ct> : enter chat with contact to
2768
2904
  script <filename> : execute commands in filename
@@ -2773,6 +2909,7 @@ def command_help():
2773
2909
  reboot : reboots node
2774
2910
  sleep <secs> : sleeps for a given amount of secs s
2775
2911
  wait_key : wait until user presses <Enter> wk
2912
+ apply_to <scope> <cmds>: sends cmds to contacts matching scope at
2776
2913
  Messenging
2777
2914
  msg <name> <msg> : send message to node by name m {
2778
2915
  wait_ack : wait an ack wa }
@@ -2847,6 +2984,30 @@ def usage () :
2847
2984
  Available Commands and shorcuts (can be chained) :""")
2848
2985
  command_help()
2849
2986
 
2987
+ def get_help_for (cmdname, context="line") :
2988
+ if cmdname == "apply_to" or cmdname == "at" :
2989
+ print("""apply_to <scope> <cmd> : applies cmd to contacts matching scope
2990
+ Scope acts like a filter with comma separated fields :
2991
+ - u, matches modification time < or > than a timestamp
2992
+ (can also be days hours or minutes ago if followed by d,h or m)
2993
+ - t, matches the type (1: client, 2: repeater, 3: room, 4: sensor)
2994
+ - h, matches number of hops
2995
+ - d, direct, similar to h>-1
2996
+ - f, flood, similar to h<0 or h=-1
2997
+
2998
+ Note: Some commands like contact_name (aka cn), reset_path (aka rp), forget_password (aka fp) can be chained.
2999
+
3000
+ Examples:
3001
+ # removes all clients that have not been updated in last 2 days
3002
+ at u<2d,t=1 remove_contact
3003
+ # gives traces to repeaters that have been updated in the last 24h and are direct
3004
+ at t=2,u>1d,d cn trace
3005
+ # tries to do flood login to all repeaters
3006
+ at t=2 rp login
3007
+ """)
3008
+ else:
3009
+ print(f"Sorry, no help yet for {cmdname}")
3010
+
2850
3011
  async def main(argv):
2851
3012
  """ Do the job """
2852
3013
  json_output = JSON
File without changes
File without changes
File without changes
File without changes
File without changes