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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcore-cli
3
- Version: 1.2.1
3
+ Version: 1.2.5
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
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.10
13
13
  Requires-Dist: meshcore>=2.1.19
14
14
  Requires-Dist: prompt-toolkit>=3.0.50
15
+ Requires-Dist: pycryptodome
15
16
  Requires-Dist: requests>=2.28.0
16
17
  Description-Content-Type: text/markdown
17
18
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore-cli"
7
- version = "1.2.1"
7
+ version = "1.2.5"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  ]
@@ -17,7 +17,7 @@ classifiers = [
17
17
  ]
18
18
  license = "MIT"
19
19
  license-files = ["LICEN[CS]E*"]
20
- dependencies = [ "meshcore >= 2.1.19", "prompt_toolkit >= 3.0.50", "requests >= 2.28.0" ]
20
+ dependencies = [ "meshcore >= 2.1.19", "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"
@@ -24,13 +24,16 @@ 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
+ from Crypto.Cipher import AES
29
+ from Crypto.Hash import HMAC, SHA256
27
30
 
28
31
  import re
29
32
 
30
33
  from meshcore import MeshCore, EventType, logger
31
34
 
32
35
  # Version
33
- VERSION = "v1.2.1"
36
+ VERSION = "v1.2.5"
34
37
 
35
38
  # default ble address is stored in a config file
36
39
  MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/"
@@ -52,6 +55,7 @@ ANSI_INVERT = "\033[7m"
52
55
  ANSI_NORMAL = "\033[27m"
53
56
  ANSI_GREEN = "\033[0;32m"
54
57
  ANSI_BGREEN = "\033[1;32m"
58
+ ANSI_DGREEN="\033[0;38;5;22m"
55
59
  ANSI_BLUE = "\033[0;34m"
56
60
  ANSI_BBLUE = "\033[1;34m"
57
61
  ANSI_RED = "\033[0;31m"
@@ -193,6 +197,60 @@ process_event_message.print_snr=False
193
197
  process_event_message.color=True
194
198
  process_event_message.last_node=None
195
199
 
200
+ async def handle_log_rx(event):
201
+ mc = handle_log_rx.mc
202
+ if handle_log_rx.json_log_rx: # json mode ... raw dump
203
+ msg = json.dumps(event.payload)
204
+ if handle_message.above:
205
+ print_above(msg)
206
+ else :
207
+ print(msg)
208
+ return
209
+
210
+ pkt = bytes().fromhex(event.payload["payload"])
211
+
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
+ channel = None
221
+ for c in await get_channels(mc):
222
+ if c["channel_hash"] == chan_hash : # validate against MAC
223
+ h = HMAC.new(bytes.fromhex(c["channel_secret"]), digestmod=SHA256)
224
+ h.update(msg)
225
+ if h.digest()[0:2] == cipher_mac:
226
+ channel = c
227
+ break
228
+
229
+ if channel is None :
230
+ if handle_log_rx.echo_unk_chans:
231
+ chan_name = chan_hash
232
+ message = msg.hex()
233
+ else:
234
+ chan_name = channel["channel_name"]
235
+ aes_key = bytes.fromhex(channel["channel_secret"])
236
+ cipher = AES.new(aes_key, AES.MODE_ECB)
237
+ message = cipher.decrypt(msg)[5:].decode("utf-8").strip("\x00")
238
+
239
+ if chan_name != "" :
240
+ width = os.get_terminal_size().columns
241
+ cars = width - 13 - 2 * path_len - len(chan_name) - 1
242
+ dispmsg = message[0:cars]
243
+ 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}"
244
+ if handle_message.above:
245
+ print_above(txt)
246
+ else:
247
+ print(txt)
248
+
249
+ handle_log_rx.json_log_rx = False
250
+ handle_log_rx.channel_echoes = False
251
+ handle_log_rx.mc = None
252
+ handle_log_rx.echo_unk_chans=False
253
+
196
254
  async def handle_advert(event):
197
255
  if not handle_advert.print_adverts:
198
256
  return
@@ -320,7 +378,7 @@ class MyNestedCompleter(NestedCompleter):
320
378
  opts = self.options.keys()
321
379
  completer = WordCompleter(
322
380
  opts, ignore_case=self.ignore_case,
323
- pattern=re.compile(r"([a-zA-Z0-9_\\/]+|[^a-zA-Z0-9_\s]+)"))
381
+ pattern=re.compile(r"([a-zA-Z0-9_\\/\#]+|[^a-zA-Z0-9_\s\#]+)"))
324
382
  yield from completer.get_completions(document, complete_event)
325
383
  else: # normal behavior for remainder
326
384
  yield from super().get_completions(document, complete_event)
@@ -341,6 +399,10 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
341
399
  for c in it :
342
400
  contact_list[c[1]['adv_name']] = None
343
401
 
402
+ pit = iter(pending.items())
403
+ for c in pit :
404
+ pending_list[c[1]['adv_name']] = None
405
+
344
406
  pit = iter(pending.items())
345
407
  for c in pit :
346
408
  pending_list[c[1]['public_key']] = None
@@ -417,6 +479,9 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
417
479
  "color" : {"on":None, "off":None},
418
480
  "print_name" : {"on":None, "off":None},
419
481
  "print_adverts" : {"on":None, "off":None},
482
+ "json_log_rx" : {"on":None, "off":None},
483
+ "channel_echoes" : {"on":None, "off":None},
484
+ "echo_unk_chans" : {"on":None, "off":None},
420
485
  "print_new_contacts" : {"on": None, "off":None},
421
486
  "print_path_updates" : {"on":None,"off":None},
422
487
  "classic_prompt" : {"on" : None, "off":None},
@@ -444,6 +509,9 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
444
509
  "color":None,
445
510
  "print_name":None,
446
511
  "print_adverts":None,
512
+ "json_log_rx":None,
513
+ "channel_echoes":None,
514
+ "echo_unk_chans":None,
447
515
  "print_path_updates":None,
448
516
  "print_new_contacts":None,
449
517
  "classic_prompt":None,
@@ -604,6 +672,14 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
604
672
 
605
673
  completion_list.update(slash_contacts_completion_list)
606
674
 
675
+ slash_chan_completion_list = {}
676
+ if not channels is None:
677
+ for c in channels :
678
+ if c["channel_name"] != "":
679
+ slash_chan_completion_list["/" + c["channel_name"]] = None
680
+
681
+ completion_list.update(slash_chan_completion_list)
682
+
607
683
  completion_list.update({
608
684
  "script" : None,
609
685
  "quit" : None
@@ -675,7 +751,7 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
675
751
  if classic :
676
752
  prompt = prompt + " > "
677
753
  else :
678
- prompt = prompt + "🭨"
754
+ prompt = prompt + f"{ANSI_NORMAL}🭬{ANSI_INVERT}"
679
755
 
680
756
  if not contact is None :
681
757
  if not last_ack:
@@ -696,7 +772,7 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
696
772
  prompt = prompt + f"{ANSI_INVERT}"
697
773
 
698
774
  if print_name and not classic :
699
- prompt = prompt + "🭬"
775
+ prompt = prompt + f"{ANSI_NORMAL}🭨{ANSI_INVERT}"
700
776
 
701
777
  prompt = prompt + f"{contact['adv_name']}"
702
778
  if classic :
@@ -728,14 +804,31 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
728
804
 
729
805
  # raw meshcli command as on command line
730
806
  elif line.startswith("$") :
731
- args = shlex.split(line[1:])
732
- await process_cmds(mc, args)
807
+ try :
808
+ args = shlex.split(line[1:])
809
+ await process_cmds(mc, args)
810
+ except ValueError:
811
+ logger.error("Error parsing line {line[1:]}")
733
812
 
734
813
  elif line.startswith("/") :
735
814
  path = line.split(" ", 1)[0]
736
815
  if path.count("/") == 1:
737
- args = shlex.split(line[1:])
738
- await process_cmds(mc, args)
816
+ args = line[1:].split(" ")
817
+ tct = mc.get_contact_by_name(args[0])
818
+ if len(args)>1 and not tct is None: # a contact, send a message
819
+ if tct["type"] == 1 or tct["type"] == 3: # client or room
820
+ last_ack = await msg_ack(mc, tct, line.split(" ", 1)[1])
821
+ else:
822
+ print("Can only send msg to chan, client or room")
823
+ else :
824
+ ch = await get_channel_by_name(mc, args[0])
825
+ if len(args)>1 and not ch is None: # a channel, send message
826
+ await send_chan_msg(mc, ch["channel_idx"], line.split(" ", 1)[1])
827
+ else :
828
+ try :
829
+ await process_cmds(mc, shlex.split(line[1:]))
830
+ except ValueError:
831
+ logger.error(f"Error processing line{line[1:]}")
739
832
  else:
740
833
  cmdline = line[1:].split("/",1)[1]
741
834
  contact_name = path[1:].split("/",1)[0]
@@ -744,10 +837,11 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
744
837
  print(f"{contact_name} is not a contact")
745
838
  else:
746
839
  if not await process_contact_chat_line(mc, tct, cmdline):
747
- if tct["type"] == 1:
748
- last_ack = await msg_ack(mc, tct, cmdline)
749
- else :
750
- await process_cmds(mc, ["cmd", tct["adv_name"], cmdline])
840
+ if cmdline != "":
841
+ if tct["type"] == 1:
842
+ last_ack = await msg_ack(mc, tct, cmdline)
843
+ else :
844
+ await process_cmds(mc, ["cmd", tct["adv_name"], cmdline])
751
845
 
752
846
  elif line.startswith("to ") : # dest
753
847
  dest = line[3:]
@@ -805,7 +899,7 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
805
899
  if ln is None :
806
900
  print("No received msg yet !")
807
901
  elif ln["type"] == 0 :
808
- await process_cmds(mc, ["chan", str(contact["chan_nb"]), line] )
902
+ await send_chan_msg(mc, ln["chan_nb"], line[1:])
809
903
  else :
810
904
  last_ack = await msg_ack(mc, ln, line[1:])
811
905
  if last_ack == False :
@@ -813,8 +907,11 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
813
907
 
814
908
  # commands are passed through if at root
815
909
  elif contact is None or line.startswith(".") :
816
- args = shlex.split(line)
817
- await process_cmds(mc, args)
910
+ try:
911
+ args = shlex.split(line)
912
+ await process_cmds(mc, args)
913
+ except ValueError:
914
+ logger.error(f"Error processing {line}")
818
915
 
819
916
  elif await process_contact_chat_line(mc, contact, line):
820
917
  pass
@@ -837,7 +934,7 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
837
934
  last_ack = await msg_ack(mc, contact, line)
838
935
 
839
936
  elif contact["type"] == 0 : # channel, send msg to channel
840
- await process_cmds(mc, ["chan", str(contact["chan_nb"]), line] )
937
+ await send_chan_msg(mc, contact["chan_nb"], line)
841
938
 
842
939
  elif contact["type"] == 1 : # chat, send to recipient and wait ack
843
940
  last_ack = await msg_ack(mc, contact, line)
@@ -970,7 +1067,18 @@ async def process_contact_chat_line(mc, contact, line):
970
1067
  password_file = ""
971
1068
  password = ""
972
1069
  if os.path.isdir(MCCLI_CONFIG_DIR) :
1070
+ # if a password file exists with node name open it and destroy it
973
1071
  password_file = MCCLI_CONFIG_DIR + contact['adv_name'] + ".pass"
1072
+ if os.path.exists(password_file) :
1073
+ with open(password_file, "r", encoding="utf-8") as f :
1074
+ password=f.readline().strip()
1075
+ os.remove(password_file)
1076
+ password_file = MCCLI_CONFIG_DIR + contact["public_key"] + ".pass"
1077
+ with open(password_file, "w", encoding="utf-8") as f :
1078
+ f.write(password)
1079
+
1080
+ # this is the new correct password file, using pubkey
1081
+ password_file = MCCLI_CONFIG_DIR + contact["public_key"] + ".pass"
974
1082
  if os.path.exists(password_file) :
975
1083
  with open(password_file, "r", encoding="utf-8") as f :
976
1084
  password=f.readline().strip()
@@ -993,6 +1101,9 @@ async def process_contact_chat_line(mc, contact, line):
993
1101
 
994
1102
  if line.startswith("forget_password") or line.startswith("fp"):
995
1103
  password_file = MCCLI_CONFIG_DIR + contact['adv_name'] + ".pass"
1104
+ if os.path.exists(password_file):
1105
+ os.remove(password_file)
1106
+ password_file = MCCLI_CONFIG_DIR + contact['public_key'] + ".pass"
996
1107
  if os.path.exists(password_file):
997
1108
  os.remove(password_file)
998
1109
  try:
@@ -1112,6 +1223,7 @@ async def set_channel (mc, chan, name, key=None):
1112
1223
  return None
1113
1224
 
1114
1225
  info = res.payload
1226
+ info["channel_hash"] = sha256(info["channel_secret"]).digest()[0:1].hex()
1115
1227
  info["channel_secret"] = info["channel_secret"].hex()
1116
1228
 
1117
1229
  if hasattr(mc,'channels') :
@@ -1200,12 +1312,14 @@ async def get_channels (mc, anim=False) :
1200
1312
  if res.type == EventType.ERROR:
1201
1313
  break
1202
1314
  info = res.payload
1315
+ info["channel_hash"] = sha256(info["channel_secret"]).digest()[0:1].hex()
1203
1316
  info["channel_secret"] = info["channel_secret"].hex()
1204
1317
  mc.channels.append(info)
1205
1318
  ch = ch + 1
1206
1319
  if anim:
1207
1320
  print(".", end="", flush=True)
1208
- print (" Done")
1321
+ if anim:
1322
+ print (" Done")
1209
1323
  return mc.channels
1210
1324
 
1211
1325
  async def print_trace_to (mc, contact):
@@ -1411,6 +1525,18 @@ async def next_cmd(mc, cmds, json_output=False):
1411
1525
  process_event_message.print_snr = (cmds[2] == "on")
1412
1526
  if json_output :
1413
1527
  print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1528
+ case "json_log_rx" :
1529
+ handle_log_rx.json_log_rx = (cmds[2] == "on")
1530
+ if json_output :
1531
+ print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1532
+ case "channel_echoes" :
1533
+ handle_log_rx.channel_echoes = (cmds[2] == "on")
1534
+ if json_output :
1535
+ print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1536
+ case "echo_unk_chans" :
1537
+ handle_log_rx.echo_unk_chans = (cmds[2] == "on")
1538
+ if json_output :
1539
+ print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1414
1540
  case "print_adverts" :
1415
1541
  handle_advert.print_adverts = (cmds[2] == "on")
1416
1542
  if json_output :
@@ -1641,6 +1767,21 @@ async def next_cmd(mc, cmds, json_output=False):
1641
1767
  print(json.dumps({"color" : process_event_message.color}))
1642
1768
  else:
1643
1769
  print(f"{'on' if process_event_message.color else 'off'}")
1770
+ case "json_log_rx":
1771
+ if json_output :
1772
+ print(json.dumps({"json_log_rx" : handle_log_rx.json_log_rx}))
1773
+ else:
1774
+ print(f"{'on' if handle_log_rx.json_log_rx else 'off'}")
1775
+ case "channel_echoes":
1776
+ if json_output :
1777
+ print(json.dumps({"channel_echoes" : handle_log_rx.channel_echoes}))
1778
+ else:
1779
+ print(f"{'on' if handle_log_rx.channel_echoes else 'off'}")
1780
+ case "echo_unk_chans":
1781
+ if json_output :
1782
+ print(json.dumps({"echo_unk_chans" : handle_log_rx.echo_unk_chans}))
1783
+ else:
1784
+ print(f"{'on' if handle_log_rx.echo_unk_chans else 'off'}")
1644
1785
  case "print_adverts":
1645
1786
  if json_output :
1646
1787
  print(json.dumps({"print_adverts" : handle_advert.print_adverts}))
@@ -2232,6 +2373,13 @@ async def next_cmd(mc, cmds, json_output=False):
2232
2373
  case "add_pending":
2233
2374
  argnum = 1
2234
2375
  contact = mc.pop_pending_contact(cmds[1])
2376
+ if contact is None: # try to find by name
2377
+ key = None
2378
+ for c in mc.pending_contacts.items():
2379
+ if c[1]['adv_name'] == cmds[1]:
2380
+ key = c[1]['public_key']
2381
+ contact = mc.pop_pending_contact(key)
2382
+ break
2235
2383
  if contact is None:
2236
2384
  if json_output:
2237
2385
  print(json.dumps({"error":"Contact does not exist"}))
@@ -2511,7 +2659,7 @@ async def next_cmd(mc, cmds, json_output=False):
2511
2659
  if json_output:
2512
2660
  await ps.prompt_async()
2513
2661
  else:
2514
- await ps.prompt_async("Press Enter to continue ...")
2662
+ await ps.prompt_async("Press Enter to continue ...\n")
2515
2663
  except (EOFError, KeyboardInterrupt, asyncio.CancelledError):
2516
2664
  pass
2517
2665
 
@@ -2570,7 +2718,7 @@ async def next_cmd(mc, cmds, json_output=False):
2570
2718
  await mc.ensure_contacts()
2571
2719
  contact = mc.get_contact_by_name(cmds[0])
2572
2720
  if contact is None:
2573
- logger.error(f"Unknown command : {cmd}, will exit ...")
2721
+ logger.error(f"Unknown command : {cmd}. {cmds} not executed ...")
2574
2722
  return None
2575
2723
 
2576
2724
  await interactive_loop(mc, to=contact)
@@ -2579,7 +2727,7 @@ async def next_cmd(mc, cmds, json_output=False):
2579
2727
  return cmds[argnum+1:]
2580
2728
 
2581
2729
  except IndexError:
2582
- logger.error("Error in parameters, returning")
2730
+ logger.error("Error in parameters")
2583
2731
  return None
2584
2732
  except EOFError:
2585
2733
  logger.error("Cancelled")
@@ -2604,8 +2752,11 @@ async def process_script(mc, file, json_output=False):
2604
2752
  line = line.strip()
2605
2753
  if not (line == "" or line[0] == "#"):
2606
2754
  logger.debug(f"processing {line}")
2607
- cmds = shlex.split(line)
2608
- await process_cmds(mc, cmds, json_output)
2755
+ try :
2756
+ cmds = shlex.split(line)
2757
+ await process_cmds(mc, cmds, json_output)
2758
+ except ValueError:
2759
+ logger.error(f"Error processing {line}")
2609
2760
 
2610
2761
  def version():
2611
2762
  print (f"meshcore-cli: command line interface to MeshCore companion radios {VERSION}")
@@ -2661,7 +2812,7 @@ def command_help():
2661
2812
  req_mma <ct> : requests min/max/avg for a sensor rm
2662
2813
  req_acl <ct> : requests access control list for sensor
2663
2814
  pending_contacts : show pending contacts
2664
- add_pending <key> : manually add pending contact from key
2815
+ add_pending <pending> : manually add pending contact
2665
2816
  flush_pending : flush pending contact list
2666
2817
  Repeaters
2667
2818
  login <name> <pwd> : log into a node (rep) with given pwd l
@@ -2881,10 +3032,12 @@ async def main(argv):
2881
3032
  handle_message.mc = mc # connect meshcore to handle_message
2882
3033
  handle_advert.mc = mc
2883
3034
  handle_path_update.mc = mc
3035
+ handle_log_rx.mc = mc
2884
3036
 
2885
3037
  mc.subscribe(EventType.ADVERTISEMENT, handle_advert)
2886
3038
  mc.subscribe(EventType.PATH_UPDATE, handle_path_update)
2887
3039
  mc.subscribe(EventType.NEW_CONTACT, handle_new_contact)
3040
+ mc.subscribe(EventType.RX_LOG_DATA, handle_log_rx)
2888
3041
 
2889
3042
  mc.auto_update_contacts = True
2890
3043
 
File without changes
File without changes
File without changes
File without changes
File without changes