meshcore-cli 1.2.4__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.4
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.4"
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.4"
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,14 +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
- path_len = pkt[1]
215
- path = pkt[2:path_len+2].hex()
216
- chan_hash = pkt[path_len+2:path_len+3].hex()
217
- cipher_mac = pkt[path_len+3:path_len+5]
218
- msg = pkt[path_len+5:]
219
224
  channel = None
220
225
  for c in await get_channels(mc):
221
226
  if c["channel_hash"] == chan_hash : # validate against MAC
@@ -225,28 +230,32 @@ async def handle_log_rx(event):
225
230
  channel = c
226
231
  break
227
232
 
233
+ chan_name = ""
234
+
228
235
  if channel is None :
229
- chan_name = chan_hash
230
- message = msg.hex()
236
+ if handle_log_rx.echo_unk_chans:
237
+ chan_name = chan_hash
238
+ message = msg.hex()
231
239
  else:
232
240
  chan_name = channel["channel_name"]
233
241
  aes_key = bytes.fromhex(channel["channel_secret"])
234
242
  cipher = AES.new(aes_key, AES.MODE_ECB)
235
243
  message = cipher.decrypt(msg)[5:].decode("utf-8").strip("\x00")
236
-
237
- width = os.get_terminal_size().columns
238
- cars = width - 13 - 2 * path_len - len(chan_name) - 1
239
- dispmsg = message[0:cars]
240
- 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}"
241
- if handle_message.above:
242
- print_above(txt)
243
- else:
244
- print(txt)
245
- return
246
-
244
+
245
+ if chan_name != "" :
246
+ width = os.get_terminal_size().columns
247
+ cars = width - 13 - 2 * path_len - len(chan_name) - 1
248
+ dispmsg = message[0:cars]
249
+ 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}"
250
+ if handle_message.above:
251
+ print_above(txt)
252
+ else:
253
+ print(txt)
254
+
247
255
  handle_log_rx.json_log_rx = False
248
256
  handle_log_rx.channel_echoes = False
249
257
  handle_log_rx.mc = None
258
+ handle_log_rx.echo_unk_chans=False
250
259
 
251
260
  async def handle_advert(event):
252
261
  if not handle_advert.print_adverts:
@@ -319,7 +328,7 @@ async def log_message(mc, msg):
319
328
  if msg["type"] == "PRIV" :
320
329
  ct = mc.get_contact_by_key_prefix(msg['pubkey_prefix'])
321
330
  if ct is None:
322
- msg["name"] = data["pubkey_prefix"]
331
+ msg["name"] = msg["pubkey_prefix"]
323
332
  else:
324
333
  msg["name"] = ct["adv_name"]
325
334
  elif msg["type"] == "CHAN" :
@@ -363,7 +372,7 @@ async def subscribe_to_msgs(mc, json_output=False, above=False):
363
372
  class MyNestedCompleter(NestedCompleter):
364
373
  def get_completions( self, document, complete_event):
365
374
  txt = document.text_before_cursor.lstrip()
366
- if not " " in txt:
375
+ if not " " in txt:
367
376
  if txt != "" and txt[0] == "/" and txt.count("/") == 1:
368
377
  opts = []
369
378
  for k in self.options.keys():
@@ -396,6 +405,10 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
396
405
  for c in it :
397
406
  contact_list[c[1]['adv_name']] = None
398
407
 
408
+ pit = iter(pending.items())
409
+ for c in pit :
410
+ pending_list[c[1]['adv_name']] = None
411
+
399
412
  pit = iter(pending.items())
400
413
  for c in pit :
401
414
  pending_list[c[1]['public_key']] = None
@@ -458,6 +471,8 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
458
471
  "set_channel": None,
459
472
  "get_channels": None,
460
473
  "remove_channel": None,
474
+ "apply_to": None,
475
+ "at": None,
461
476
  "set" : {
462
477
  "name" : None,
463
478
  "pin" : None,
@@ -474,6 +489,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
474
489
  "print_adverts" : {"on":None, "off":None},
475
490
  "json_log_rx" : {"on":None, "off":None},
476
491
  "channel_echoes" : {"on":None, "off":None},
492
+ "echo_unk_chans" : {"on":None, "off":None},
477
493
  "print_new_contacts" : {"on": None, "off":None},
478
494
  "print_path_updates" : {"on":None,"off":None},
479
495
  "classic_prompt" : {"on" : None, "off":None},
@@ -503,6 +519,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
503
519
  "print_adverts":None,
504
520
  "json_log_rx":None,
505
521
  "channel_echoes":None,
522
+ "echo_unk_chans":None,
506
523
  "print_path_updates":None,
507
524
  "print_new_contacts":None,
508
525
  "classic_prompt":None,
@@ -522,6 +539,8 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
522
539
 
523
540
  contact_completion_list = {
524
541
  "contact_info": None,
542
+ "contact_name": None,
543
+ "contact_lastmod": None,
525
544
  "export_contact" : None,
526
545
  "share_contact" : None,
527
546
  "upload_contact" : None,
@@ -643,7 +662,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
643
662
  slash_root_completion_list = {}
644
663
  for k,v in root_completion_list.items():
645
664
  slash_root_completion_list["/"+k]=v
646
-
665
+
647
666
  completion_list.update(slash_root_completion_list)
648
667
 
649
668
  slash_contacts_completion_list = {}
@@ -793,6 +812,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
793
812
  if line == "" : # blank line
794
813
  pass
795
814
 
815
+ elif line.startswith("?") :
816
+ get_help_for(line[1:], context="chat")
817
+
796
818
  # raw meshcli command as on command line
797
819
  elif line.startswith("$") :
798
820
  try :
@@ -896,6 +918,13 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
896
918
  if last_ack == False :
897
919
  contact = ln
898
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
+
899
928
  # commands are passed through if at root
900
929
  elif contact is None or line.startswith(".") :
901
930
  try:
@@ -957,6 +986,23 @@ async def process_contact_chat_line(mc, contact, line):
957
986
  await process_cmds(mc, args)
958
987
  return True
959
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
+
960
1006
  # commands that take contact as second arg will be sent to recipient
961
1007
  if line == "sc" or line == "share_contact" or\
962
1008
  line == "ec" or line == "export_contact" or\
@@ -1044,7 +1090,7 @@ async def process_contact_chat_line(mc, contact, line):
1044
1090
  return True
1045
1091
 
1046
1092
  # same but for commands with a parameter
1047
- if line.startswith("cmd ") or\
1093
+ if line.startswith("cmd ") or line.startswith("msg ") or\
1048
1094
  line.startswith("cp ") or line.startswith("change_path ") or\
1049
1095
  line.startswith("cf ") or line.startswith("change_flags ") or\
1050
1096
  line.startswith("req_binary ") or\
@@ -1076,7 +1122,7 @@ async def process_contact_chat_line(mc, contact, line):
1076
1122
 
1077
1123
  if password == "":
1078
1124
  try:
1079
- sess = PromptSession("Password: ", is_password=True)
1125
+ sess = PromptSession(f"Password for {contact['adv_name']}: ", is_password=True)
1080
1126
  password = await sess.prompt_async()
1081
1127
  except EOFError:
1082
1128
  logger.info("Canceled")
@@ -1115,6 +1161,89 @@ async def process_contact_chat_line(mc, contact, line):
1115
1161
 
1116
1162
  return False
1117
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']}")
1118
1247
 
1119
1248
  async def send_cmd (mc, contact, cmd) :
1120
1249
  res = await mc.commands.send_cmd(contact, cmd)
@@ -1386,6 +1515,11 @@ async def next_cmd(mc, cmds, json_output=False):
1386
1515
  """ process next command """
1387
1516
  try :
1388
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
+
1389
1523
  if cmds[0].startswith(".") : # override json_output
1390
1524
  json_output = True
1391
1525
  cmd = cmds[0][1:]
@@ -1476,6 +1610,10 @@ async def next_cmd(mc, cmds, json_output=False):
1476
1610
  else:
1477
1611
  print("Time set")
1478
1612
 
1613
+ case "apply_to"|"at":
1614
+ argnum = 2
1615
+ await apply_command_to_contacts(mc, cmds[1], cmds[2])
1616
+
1479
1617
  case "set":
1480
1618
  argnum = 2
1481
1619
  match cmds[1]:
@@ -1524,6 +1662,10 @@ async def next_cmd(mc, cmds, json_output=False):
1524
1662
  handle_log_rx.channel_echoes = (cmds[2] == "on")
1525
1663
  if json_output :
1526
1664
  print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1665
+ case "echo_unk_chans" :
1666
+ handle_log_rx.echo_unk_chans = (cmds[2] == "on")
1667
+ if json_output :
1668
+ print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1527
1669
  case "print_adverts" :
1528
1670
  handle_advert.print_adverts = (cmds[2] == "on")
1529
1671
  if json_output :
@@ -1764,6 +1906,11 @@ async def next_cmd(mc, cmds, json_output=False):
1764
1906
  print(json.dumps({"channel_echoes" : handle_log_rx.channel_echoes}))
1765
1907
  else:
1766
1908
  print(f"{'on' if handle_log_rx.channel_echoes else 'off'}")
1909
+ case "echo_unk_chans":
1910
+ if json_output :
1911
+ print(json.dumps({"echo_unk_chans" : handle_log_rx.echo_unk_chans}))
1912
+ else:
1913
+ print(f"{'on' if handle_log_rx.echo_unk_chans else 'off'}")
1767
1914
  case "print_adverts":
1768
1915
  if json_output :
1769
1916
  print(json.dumps({"print_adverts" : handle_advert.print_adverts}))
@@ -1961,6 +2108,12 @@ async def next_cmd(mc, cmds, json_output=False):
1961
2108
  if res is None:
1962
2109
  print("Error setting channel")
1963
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
+
1964
2117
  case "remove_channel":
1965
2118
  argnum = 1
1966
2119
  res = await set_channel(mc, cmds[1], "", bytes.fromhex(16*"00"))
@@ -2355,6 +2508,13 @@ async def next_cmd(mc, cmds, json_output=False):
2355
2508
  case "add_pending":
2356
2509
  argnum = 1
2357
2510
  contact = mc.pop_pending_contact(cmds[1])
2511
+ if contact is None: # try to find by name
2512
+ key = None
2513
+ for c in mc.pending_contacts.items():
2514
+ if c[1]['adv_name'] == cmds[1]:
2515
+ key = c[1]['public_key']
2516
+ contact = mc.pop_pending_contact(key)
2517
+ break
2358
2518
  if contact is None:
2359
2519
  if json_output:
2360
2520
  print(json.dumps({"error":"Contact does not exist"}))
@@ -2693,7 +2853,7 @@ async def next_cmd(mc, cmds, json_output=False):
2693
2853
  await mc.ensure_contacts()
2694
2854
  contact = mc.get_contact_by_name(cmds[0])
2695
2855
  if contact is None:
2696
- logger.error(f"Unknown command : {cmd}, will exit ...")
2856
+ logger.error(f"Unknown command : {cmd}, {cmds} not executed ...")
2697
2857
  return None
2698
2858
 
2699
2859
  await interactive_loop(mc, to=contact)
@@ -2702,7 +2862,7 @@ async def next_cmd(mc, cmds, json_output=False):
2702
2862
  return cmds[argnum+1:]
2703
2863
 
2704
2864
  except IndexError:
2705
- logger.error("Error in parameters, returning")
2865
+ logger.error("Error in parameters")
2706
2866
  return None
2707
2867
  except EOFError:
2708
2868
  logger.error("Cancelled")
@@ -2737,7 +2897,8 @@ def version():
2737
2897
  print (f"meshcore-cli: command line interface to MeshCore companion radios {VERSION}")
2738
2898
 
2739
2899
  def command_help():
2740
- print(""" General commands
2900
+ print(""" ?<cmd> may give you some more help about cmd
2901
+ General commands
2741
2902
  chat : enter the chat (interactive) mode
2742
2903
  chat_to <ct> : enter chat with contact to
2743
2904
  script <filename> : execute commands in filename
@@ -2748,6 +2909,7 @@ def command_help():
2748
2909
  reboot : reboots node
2749
2910
  sleep <secs> : sleeps for a given amount of secs s
2750
2911
  wait_key : wait until user presses <Enter> wk
2912
+ apply_to <scope> <cmds>: sends cmds to contacts matching scope at
2751
2913
  Messenging
2752
2914
  msg <name> <msg> : send message to node by name m {
2753
2915
  wait_ack : wait an ack wa }
@@ -2787,7 +2949,7 @@ def command_help():
2787
2949
  req_mma <ct> : requests min/max/avg for a sensor rm
2788
2950
  req_acl <ct> : requests access control list for sensor
2789
2951
  pending_contacts : show pending contacts
2790
- add_pending <key> : manually add pending contact from key
2952
+ add_pending <pending> : manually add pending contact
2791
2953
  flush_pending : flush pending contact list
2792
2954
  Repeaters
2793
2955
  login <name> <pwd> : log into a node (rep) with given pwd l
@@ -2822,6 +2984,30 @@ def usage () :
2822
2984
  Available Commands and shorcuts (can be chained) :""")
2823
2985
  command_help()
2824
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
+
2825
3011
  async def main(argv):
2826
3012
  """ Do the job """
2827
3013
  json_output = JSON
File without changes
File without changes
File without changes
File without changes
File without changes