meshcore-cli 1.3.11__py3-none-any.whl → 1.3.13__py3-none-any.whl

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.
@@ -32,7 +32,7 @@ import re
32
32
  from meshcore import MeshCore, EventType, logger
33
33
 
34
34
  # Version
35
- VERSION = "v1.3.11"
35
+ VERSION = "v1.3.12"
36
36
 
37
37
  # default ble address is stored in a config file
38
38
  MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/"
@@ -72,6 +72,8 @@ ANSI_LIGHT_GREEN = "\033[0;92m"
72
72
  ANSI_LIGHT_YELLOW = "\033[0;93m"
73
73
  ANSI_LIGHT_GRAY="\033[0;38;5;247m"
74
74
  ANSI_BGRAY="\033[1;38;5;247m"
75
+ ANSI_GRAY_BACK="\033[48;5;247m"
76
+ ANSI_RESET_BACK="\033[49m"
75
77
  ANSI_ORANGE="\033[0;38;5;214m"
76
78
  ANSI_BORANGE="\033[1;38;5;214m"
77
79
  #ANSI_YELLOW="\033[0;38;5;226m"
@@ -82,8 +84,9 @@ ANSI_BYELLOW = "\033[1;33m"
82
84
  #Unicode chars
83
85
  # some possible symbols for prompts 🭬🬛🬗🭬🬛🬃🬗🭬🬛🬃🬗🬏🭀🭋🭨🮋
84
86
  ARROW_HEAD = ""
85
- SLASH_END = ""
86
- SLASH_START = ""
87
+ SLASH_END = f"{ANSI_RESET_BACK}"
88
+ #SLASH_START = ""
89
+ SLASH_START = f"{ANSI_GRAY_BACK}"
87
90
  INVERT_SLASH = False
88
91
 
89
92
  def escape_ansi(line):
@@ -294,7 +297,7 @@ async def handle_log_rx(event):
294
297
  chan_name = channel["channel_name"]
295
298
  aes_key = bytes.fromhex(channel["channel_secret"])
296
299
  cipher = AES.new(aes_key, AES.MODE_ECB)
297
- message = cipher.decrypt(msg)[5:].decode("utf-8").strip("\x00")
300
+ message = cipher.decrypt(msg)[5:].decode("utf-8", "ignore").strip("\x00")
298
301
 
299
302
  if chan_name != "" :
300
303
  width = os.get_terminal_size().columns
@@ -324,7 +327,7 @@ async def handle_log_rx(event):
324
327
  if flags & 0x40 > 0: #has feature2
325
328
  adv_feat2 = pk_buf.read(2).hex()
326
329
  if flags & 0x80 > 0: #has name
327
- adv_name = pk_buf.read().decode("utf-8").strip("\x00")
330
+ adv_name = pk_buf.read().decode("utf-8", "ignore").strip("\x00")
328
331
 
329
332
  if adv_name is None:
330
333
  # try to get the name from the contact
@@ -443,10 +446,16 @@ async def log_message(mc, msg):
443
446
  ct = mc.get_contact_by_key_prefix(msg['pubkey_prefix'])
444
447
  if ct is None:
445
448
  msg["name"] = msg["pubkey_prefix"]
449
+ msg["sender"] = msg["pubkey_prefix"]
446
450
  else:
447
451
  msg["name"] = ct["adv_name"]
452
+ msg["sender"] = ct["adv_name"]
448
453
  elif msg["type"] == "CHAN" :
449
- msg["name"] = f"channel {msg['channel_idx']}"
454
+ if hasattr(mc, 'channels') :
455
+ msg["sender"] = mc.channels[msg['channel_idx']]["channel_name"]
456
+ else:
457
+ msg["sender"] = f"channel {msg['channel_idx']}"
458
+ msg["name"] = msg["sender"]
450
459
  msg["timestamp"] = int(time.time())
451
460
 
452
461
  with open(log_message.file, "a") as logfile:
@@ -1019,7 +1028,7 @@ Some cmds have an help accessible with ?<cmd>. Do ?[Tab] to get a list.
1019
1028
  if '%' in dest and scope!=None :
1020
1029
  dest_scope = dest.split("%")[-1]
1021
1030
  dest = dest[:-len(dest_scope)-1]
1022
- nc = mc.get_contact_by_name(dest)
1031
+ nc = await get_contact_from_arg(mc, dest)
1023
1032
  if nc is None:
1024
1033
  if dest == "public" :
1025
1034
  nc = {"adv_name" : "public", "type" : 0, "chan_nb" : 0}
@@ -1074,7 +1083,7 @@ Some cmds have an help accessible with ?<cmd>. Do ?[Tab] to get a list.
1074
1083
  dest_scope = dest.split("%")[-1]
1075
1084
  dest = dest[:-len(dest_scope)-1]
1076
1085
  await set_scope (mc, dest_scope)
1077
- tct = mc.get_contact_by_name(dest)
1086
+ tct = get_contact_from_arg(mc, dest)
1078
1087
  if len(args)>1 and not tct is None: # a contact, send a message
1079
1088
  if tct["type"] == 1 or tct["type"] == 3: # client or room
1080
1089
  last_ack = await msg_ack(mc, tct, line.split(" ", 1)[1])
@@ -1097,7 +1106,7 @@ Some cmds have an help accessible with ?<cmd>. Do ?[Tab] to get a list.
1097
1106
  dest_scope = contact_name.split("%")[-1]
1098
1107
  contact_name = contact_name[:-len(dest_scope)-1]
1099
1108
  await set_scope (mc, dest_scope)
1100
- tct = mc.get_contact_by_name(contact_name)
1109
+ tct = mc.get_contact_from_arg(mc, contact_name)
1101
1110
  if tct is None:
1102
1111
  print(f"{contact_name} is not a contact")
1103
1112
  else:
@@ -1337,9 +1346,7 @@ async def process_contact_chat_line(mc, contact, line):
1337
1346
  perm = int(perm_string[1:])
1338
1347
  else:
1339
1348
  perm = int(perm_string,16)
1340
- ct=mc.get_contact_by_name(name)
1341
- if ct is None:
1342
- ct=mc.get_contact_by_key_prefix(name)
1349
+ ct= await get_contact_from_arg(mc, name)
1343
1350
  if ct is None:
1344
1351
  if name == "self" or mc.self_info["public_key"].startswith(name):
1345
1352
  key = mc.self_info["public_key"]
@@ -1538,10 +1545,11 @@ async def send_cmd (mc, contact, cmd) :
1538
1545
  if isinstance(contact, dict):
1539
1546
  sent = res.payload.copy()
1540
1547
  sent["type"] = "SENT_CMD"
1541
- sent["name"] = contact["adv_name"]
1548
+ sent["recipient"] = contact["adv_name"]
1542
1549
  sent["text"] = cmd
1543
1550
  sent["txt_type"] = 1
1544
- sent["name"] = mc.self_info['name']
1551
+ sent["sender"] = mc.self_info['name']
1552
+ sent["name"] = sent["recipient"]
1545
1553
  await log_message(mc, sent)
1546
1554
  return res
1547
1555
 
@@ -1551,9 +1559,16 @@ async def send_chan_msg(mc, nb, msg):
1551
1559
  sent = res.payload.copy()
1552
1560
  sent["type"] = "SENT_CHAN"
1553
1561
  sent["channel_idx"] = nb
1562
+ if hasattr(mc, "channels"):
1563
+ chan_name = mc.channels[nb]["channel_name"]
1564
+ else:
1565
+ chan_name = f"channel {nb}"
1566
+ sent["chan_name"] = chan_name
1567
+ sent["recipient"] = chan_name
1554
1568
  sent["text"] = msg
1555
1569
  sent["txt_type"] = 0
1556
- sent["name"] = mc.self_info['name']
1570
+ sent["sender"] = mc.self_info['name']
1571
+ sent["name"] = chan_name
1557
1572
  await log_message(mc, sent)
1558
1573
  return res
1559
1574
 
@@ -1564,10 +1579,11 @@ async def send_msg (mc, contact, msg) :
1564
1579
  if isinstance(contact, dict):
1565
1580
  sent = res.payload.copy()
1566
1581
  sent["type"] = "SENT_MSG"
1567
- sent["name"] = contact["adv_name"]
1582
+ sent["recipient"] = contact["adv_name"]
1568
1583
  sent["text"] = msg
1569
1584
  sent["txt_type"] = 0
1570
- sent["name"] = mc.self_info['name']
1585
+ sent["sender"] = mc.self_info['name']
1586
+ sent["name"] = sent["recipient"]
1571
1587
  await log_message(mc, sent)
1572
1588
  return res
1573
1589
 
@@ -1584,10 +1600,11 @@ async def msg_ack (mc, contact, msg) :
1584
1600
  if isinstance(contact, dict):
1585
1601
  sent = res.payload.copy()
1586
1602
  sent["type"] = "SENT_MSG"
1587
- sent["name"] = contact["adv_name"]
1603
+ sent["recipient"] = contact["adv_name"]
1588
1604
  sent["text"] = msg
1589
1605
  sent["txt_type"] = 0
1590
- sent["name"] = mc.self_info['name']
1606
+ sent["sender"] = mc.self_info['name']
1607
+ sent["name"] = sent["recipient"]
1591
1608
  await log_message(mc, sent)
1592
1609
  return not res is None
1593
1610
  msg_ack.max_attempts=3
@@ -1828,6 +1845,22 @@ async def print_disc_trace_to (mc, contact):
1828
1845
 
1829
1846
  await next_cmd(mc, ["trace", trace])
1830
1847
 
1848
+
1849
+ async def get_contact_from_arg(mc, arg):
1850
+ contact = None
1851
+ await mc.ensure_contacts()
1852
+
1853
+ # first try with key prefix
1854
+ try: # try only if its a valid hex
1855
+ int(arg ,16)
1856
+ contact = mc.get_contact_by_key_prefix(arg)
1857
+ except ValueError:
1858
+ pass
1859
+ if contact is None: # try by name
1860
+ contact = mc.get_contact_by_name(arg)
1861
+
1862
+ return contact
1863
+
1831
1864
  async def next_cmd(mc, cmds, json_output=False):
1832
1865
  """ process next command """
1833
1866
  global ARROW_HEAD, SLASH_START, SLASH_END, INVERT_SLASH
@@ -2478,8 +2511,7 @@ async def next_cmd(mc, cmds, json_output=False):
2478
2511
  dest = None
2479
2512
 
2480
2513
  if dest is None:
2481
- await mc.ensure_contacts()
2482
- dest = mc.get_contact_by_name(cmds[1])
2514
+ dest = await get_contact_from_arg(mc, cmds[1])
2483
2515
 
2484
2516
  if dest is None:
2485
2517
  if json_output :
@@ -2500,7 +2532,9 @@ async def next_cmd(mc, cmds, json_output=False):
2500
2532
  if cmds[1].isnumeric() :
2501
2533
  nb = int(cmds[1])
2502
2534
  else:
2503
- nb = get_channel_by_name(mc, cmds[1])['channel_idx']
2535
+ chan = await get_channel_by_name(mc, cmds[1])
2536
+ print (chan)
2537
+ nb = chan['channel_idx']
2504
2538
  res = await send_chan_msg(mc, nb, cmds[2])
2505
2539
  logger.debug(res)
2506
2540
  if res.type == EventType.ERROR:
@@ -2528,8 +2562,7 @@ async def next_cmd(mc, cmds, json_output=False):
2528
2562
  dest = None
2529
2563
 
2530
2564
  if dest is None:
2531
- await mc.ensure_contacts()
2532
- dest = mc.get_contact_by_name(cmds[1])
2565
+ dest = await get_contact_from_arg(mc, cmds[1])
2533
2566
 
2534
2567
  if dest is None:
2535
2568
  if json_output :
@@ -2606,9 +2639,9 @@ async def next_cmd(mc, cmds, json_output=False):
2606
2639
 
2607
2640
  case "login" | "l" :
2608
2641
  argnum = 2
2609
- await mc.ensure_contacts()
2610
- contact = mc.get_contact_by_name(cmds[1])
2611
- if contact is None:
2642
+ contact = await get_contact_from_arg(mc, cmds[1])
2643
+
2644
+ if contact is None: # still none ? contact not found
2612
2645
  if json_output :
2613
2646
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
2614
2647
  else:
@@ -2645,8 +2678,7 @@ async def next_cmd(mc, cmds, json_output=False):
2645
2678
 
2646
2679
  case "logout" :
2647
2680
  argnum = 1
2648
- await mc.ensure_contacts()
2649
- contact = mc.get_contact_by_name(cmds[1])
2681
+ contact = await get_contact_from_arg(mc, cmds[1])
2650
2682
  res = await mc.commands.send_logout(contact)
2651
2683
  logger.debug(res)
2652
2684
  if res.type == EventType.ERROR:
@@ -2658,14 +2690,12 @@ async def next_cmd(mc, cmds, json_output=False):
2658
2690
 
2659
2691
  case "contact_timeout" :
2660
2692
  argnum = 2
2661
- await mc.ensure_contacts()
2662
- contact = mc.get_contact_by_name(cmds[1])
2693
+ contact = await get_contact_from_args(mc, cmds[1])
2663
2694
  contact["timeout"] = float(cmds[2])
2664
2695
 
2665
2696
  case "disc_path" | "dp" :
2666
2697
  argnum = 1
2667
- await mc.ensure_contacts()
2668
- contact = mc.get_contact_by_name(cmds[1])
2698
+ contact = await get_contact_from_arg(mc, cmds[1])
2669
2699
  res = await discover_path(mc, contact)
2670
2700
  if res is None:
2671
2701
  print(f"Error while discovering path")
@@ -2746,7 +2776,7 @@ async def next_cmd(mc, cmds, json_output=False):
2746
2776
  case "req_telemetry"|"rt" :
2747
2777
  argnum = 1
2748
2778
  await mc.ensure_contacts()
2749
- contact = mc.get_contact_by_name(cmds[1])
2779
+ contact = await get_contact_from_arg(mc, cmds[1])
2750
2780
  timeout = 0 if not "timeout" in contact else contact["timeout"]
2751
2781
  res = await mc.commands.req_telemetry_sync(contact, timeout)
2752
2782
  if res is None :
@@ -2763,8 +2793,7 @@ async def next_cmd(mc, cmds, json_output=False):
2763
2793
 
2764
2794
  case "req_status"|"rs" :
2765
2795
  argnum = 1
2766
- await mc.ensure_contacts()
2767
- contact = mc.get_contact_by_name(cmds[1])
2796
+ contact = await get_contact_from_arg(mc, cmds[1])
2768
2797
  timeout = 0 if not "timeout" in contact else contact["timeout"]
2769
2798
  res = await mc.commands.req_status_sync(contact, timeout)
2770
2799
  if res is None :
@@ -2778,7 +2807,7 @@ async def next_cmd(mc, cmds, json_output=False):
2778
2807
  case "req_mma" | "rm":
2779
2808
  argnum = 3
2780
2809
  await mc.ensure_contacts()
2781
- contact = mc.get_contact_by_name(cmds[1])
2810
+ contact = await get_contact_from_arg(mc, cmds[1])
2782
2811
  if cmds[2][-1] == "s":
2783
2812
  from_secs = int(cmds[2][0:-1])
2784
2813
  elif cmds[2][-1] == "m":
@@ -2807,8 +2836,7 @@ async def next_cmd(mc, cmds, json_output=False):
2807
2836
 
2808
2837
  case "req_acl" :
2809
2838
  argnum = 1
2810
- await mc.ensure_contacts()
2811
- contact = mc.get_contact_by_name(cmds[1])
2839
+ contact = await get_contact_from_arg(mc, cmds[1])
2812
2840
  timeout = 0 if not "timeout" in contact else contact["timeout"]
2813
2841
  res = await mc.commands.req_acl_sync(contact, timeout)
2814
2842
  if res is None :
@@ -2832,8 +2860,7 @@ async def next_cmd(mc, cmds, json_output=False):
2832
2860
 
2833
2861
  case "req_neighbours"|"rn" :
2834
2862
  argnum = 1
2835
- await mc.ensure_contacts()
2836
- contact = mc.get_contact_by_name(cmds[1])
2863
+ contact = await get_contact_from_arg(mc, cmds[1])
2837
2864
  timeout = 0 if not "timeout" in contact else contact["timeout"]
2838
2865
  res = await mc.commands.fetch_all_neighbours(contact, timeout=timeout)
2839
2866
  if res is None :
@@ -2872,8 +2899,7 @@ async def next_cmd(mc, cmds, json_output=False):
2872
2899
 
2873
2900
  case "req_binary" :
2874
2901
  argnum = 2
2875
- await mc.ensure_contacts()
2876
- contact = mc.get_contact_by_name(cmds[1])
2902
+ contact = await get_contact_from_arg(mc, cmds[1])
2877
2903
  timeout = 0 if not "timeout" in contact else contact["timeout"]
2878
2904
  res = await mc.commands.req_binary(contact, bytes.fromhex(cmds[2]), timeout)
2879
2905
  if res is None :
@@ -2947,8 +2973,7 @@ async def next_cmd(mc, cmds, json_output=False):
2947
2973
 
2948
2974
  case "path":
2949
2975
  argnum = 1
2950
- res = await mc.ensure_contacts(follow=True)
2951
- contact = mc.get_contact_by_name(cmds[1])
2976
+ contact = await get_contact_from_arg(mc, cmds[1])
2952
2977
  if contact is None:
2953
2978
  if json_output :
2954
2979
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -2972,7 +2997,7 @@ async def next_cmd(mc, cmds, json_output=False):
2972
2997
  case "contact_info" | "ci":
2973
2998
  argnum = 1
2974
2999
  res = await mc.ensure_contacts(follow=True)
2975
- contact = mc.get_contact_by_name(cmds[1])
3000
+ contact = await get_contact_from_arg(mc, cmds[1])
2976
3001
  if contact is None:
2977
3002
  if json_output :
2978
3003
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -2983,8 +3008,7 @@ async def next_cmd(mc, cmds, json_output=False):
2983
3008
 
2984
3009
  case "change_path" | "cp":
2985
3010
  argnum = 2
2986
- await mc.ensure_contacts()
2987
- contact = mc.get_contact_by_name(cmds[1])
3011
+ contact = await get_contact_from_arg(mc, cmds[1])
2988
3012
  if contact is None:
2989
3013
  if json_output :
2990
3014
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -3006,8 +3030,7 @@ async def next_cmd(mc, cmds, json_output=False):
3006
3030
 
3007
3031
  case "change_flags" | "cf":
3008
3032
  argnum = 2
3009
- await mc.ensure_contacts()
3010
- contact = mc.get_contact_by_name(cmds[1])
3033
+ contact = await get_contact_from_arg(mc, cmds[1])
3011
3034
  if contact is None:
3012
3035
  if json_output :
3013
3036
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -3023,8 +3046,7 @@ async def next_cmd(mc, cmds, json_output=False):
3023
3046
 
3024
3047
  case "reset_path" | "rp" :
3025
3048
  argnum = 1
3026
- await mc.ensure_contacts()
3027
- contact = mc.get_contact_by_name(cmds[1])
3049
+ contact = await get_contact_from_arg(mc, cmds[1])
3028
3050
  if contact is None:
3029
3051
  if json_output :
3030
3052
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -3043,8 +3065,7 @@ async def next_cmd(mc, cmds, json_output=False):
3043
3065
 
3044
3066
  case "share_contact" | "sc":
3045
3067
  argnum = 1
3046
- await mc.ensure_contacts()
3047
- contact = mc.get_contact_by_name(cmds[1])
3068
+ contact = await get_contact_from_arg(mc, cmds[1])
3048
3069
  if contact is None:
3049
3070
  if json_output :
3050
3071
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -3060,8 +3081,7 @@ async def next_cmd(mc, cmds, json_output=False):
3060
3081
 
3061
3082
  case "export_contact"|"ec":
3062
3083
  argnum = 1
3063
- await mc.ensure_contacts()
3064
- contact = mc.get_contact_by_name(cmds[1])
3084
+ contact = await get_contact_from_arg(mc, cmds[1])
3065
3085
  if contact is None:
3066
3086
  if json_output :
3067
3087
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -3091,8 +3111,7 @@ async def next_cmd(mc, cmds, json_output=False):
3091
3111
 
3092
3112
  case "upload_contact" | "uc" :
3093
3113
  argnum = 1
3094
- await mc.ensure_contacts()
3095
- contact = mc.get_contact_by_name(cmds[1])
3114
+ contact = await get_contact_from_arg(mc, cmds[1])
3096
3115
  if contact is None:
3097
3116
  if json_output :
3098
3117
  print(json.dumps({"error" : "contact unknown", "name" : cmds[1]}))
@@ -3136,7 +3155,6 @@ async def next_cmd(mc, cmds, json_output=False):
3136
3155
 
3137
3156
  case "remove_contact" :
3138
3157
  argnum = 1
3139
- await mc.ensure_contacts()
3140
3158
  contact = mc.get_contact_by_name(cmds[1])
3141
3159
  if contact is None:
3142
3160
  if json_output :
@@ -3258,8 +3276,7 @@ async def next_cmd(mc, cmds, json_output=False):
3258
3276
 
3259
3277
  case "chat_to" | "imto" | "to" :
3260
3278
  argnum = 1
3261
- await mc.ensure_contacts()
3262
- contact = mc.get_contact_by_name(cmds[1])
3279
+ contact = await get_contact_from_arg(mc, cmds[1])
3263
3280
  await interactive_loop(mc, to=contact)
3264
3281
 
3265
3282
  case "script" :
@@ -3267,8 +3284,7 @@ async def next_cmd(mc, cmds, json_output=False):
3267
3284
  await process_script(mc, cmds[1], json_output=json_output)
3268
3285
 
3269
3286
  case _ :
3270
- await mc.ensure_contacts()
3271
- contact = mc.get_contact_by_name(cmds[0])
3287
+ contact = await get_contact_from_arg(mc, cmds[0])
3272
3288
  if contact is None:
3273
3289
  logger.error(f"Unknown command : {cmd}, {cmds} not executed ...")
3274
3290
  return None
@@ -3408,6 +3424,7 @@ def command_usage() :
3408
3424
  -b <baudrate> : specify baudrate
3409
3425
  -C : toggles classic mode for prompt
3410
3426
  -c <on/off> : disables most of color output if off
3427
+ -r : repeater mode (raw text CLI, use with -s)
3411
3428
  """)
3412
3429
 
3413
3430
  def get_help_for (cmdname, context="line") :
@@ -3494,7 +3511,7 @@ def get_help_for (cmdname, context="line") :
3494
3511
  json_log_rx <on/off> : logs packets incoming to device as json
3495
3512
  channel_echoes <on/off> : print repeats for channel data
3496
3513
  advert_echoes <on/off> : print repeats for adverts
3497
- echo_unk_channels <on/off> : also dump unk channels (encrypted)
3514
+ echo_unk_chans <on/off> : also dump unk channels (encrypted)
3498
3515
  color <on/off> : color off should remove ANSI codes from output
3499
3516
  meshcore-cli behaviour:
3500
3517
  classic_prompt <on/off> : activates less fancier prompt
@@ -3567,6 +3584,223 @@ To remove a channel, use remove_channel, either with channel name or number.
3567
3584
  else:
3568
3585
  print(f"Sorry, no help yet for {cmdname}")
3569
3586
 
3587
+ # Repeater mode history file
3588
+ MCCLI_REPEATER_HISTORY_FILE = MCCLI_CONFIG_DIR + "repeater_history"
3589
+
3590
+ # Repeater command completion dictionary
3591
+ REPEATER_COMMANDS = {
3592
+ "ver": None,
3593
+ "board": None,
3594
+ "reboot": None,
3595
+ "advert": None,
3596
+ "clock": {"sync": None},
3597
+ "time": None,
3598
+ "neighbors": None,
3599
+ "stats-core": None,
3600
+ "stats-radio": None,
3601
+ "stats-packets": None,
3602
+ "clear": {"stats": None},
3603
+ "log": {"start": None, "stop": None, "erase": None},
3604
+ "get": {
3605
+ "name": None, "radio": None, "tx": None, "freq": None,
3606
+ "public.key": None, "prv.key": None, "repeat": None, "role": None,
3607
+ "lat": None, "lon": None, "af": None,
3608
+ "rxdelay": None, "txdelay": None, "direct.txdelay": None,
3609
+ "flood.max": None, "flood.advert.interval": None,
3610
+ "advert.interval": None, "guest.password": None,
3611
+ "allow.read.only": None, "multi.acks": None,
3612
+ "int.thresh": None, "agc.reset.interval": None,
3613
+ "bridge.enabled": None, "bridge.delay": None,
3614
+ "bridge.source": None, "bridge.baud": None,
3615
+ "bridge.channel": None, "bridge.secret": None, "bridge.type": None,
3616
+ "adc.multiplier": None, "acl": None,
3617
+ },
3618
+ "set": {
3619
+ "name": None, "radio": None, "tx": None, "freq": None,
3620
+ "prv.key": None, "repeat": {"on": None, "off": None},
3621
+ "lat": None, "lon": None, "af": None,
3622
+ "rxdelay": None, "txdelay": None, "direct.txdelay": None,
3623
+ "flood.max": None, "flood.advert.interval": None,
3624
+ "advert.interval": None, "guest.password": None,
3625
+ "allow.read.only": {"on": None, "off": None},
3626
+ "multi.acks": None, "int.thresh": None, "agc.reset.interval": None,
3627
+ "bridge.enabled": {"on": None, "off": None},
3628
+ "bridge.delay": None, "bridge.source": None,
3629
+ "bridge.baud": None, "bridge.channel": None, "bridge.secret": None,
3630
+ "adc.multiplier": None,
3631
+ },
3632
+ "password": None,
3633
+ "erase": None,
3634
+ "gps": {"on": None, "off": None, "sync": None, "setloc": None, "advert": {"none": None, "share": None, "prefs": None}},
3635
+ "sensor": {"list": None, "get": None, "set": None},
3636
+ "region": {"get": None, "put": None, "remove": None, "save": None, "load": None, "home": None, "allowf": None, "denyf": None},
3637
+ "setperm": None,
3638
+ "tempradio": None,
3639
+ "neighbor.remove": None,
3640
+ "quit": None,
3641
+ "q": None,
3642
+ "help": None,
3643
+ }
3644
+
3645
+ REPEATER_HELP = f"""
3646
+ {ANSI_BCYAN}Repeater CLI Commands:{ANSI_END}
3647
+
3648
+ {ANSI_BGREEN}Info:{ANSI_END}
3649
+ ver - Firmware version
3650
+ board - Board name
3651
+ clock - Show current time
3652
+
3653
+ {ANSI_BGREEN}Stats:{ANSI_END}
3654
+ stats-core - Core stats (uptime, battery, queue)
3655
+ stats-radio - Radio stats (RSSI, SNR, noise floor)
3656
+ stats-packets - Packet statistics (sent/recv counts)
3657
+ clear stats - Reset all statistics
3658
+
3659
+ {ANSI_BGREEN}Network:{ANSI_END}
3660
+ neighbors - Show neighboring repeaters (zero-hop)
3661
+ advert - Send advertisement now
3662
+
3663
+ {ANSI_BGREEN}Logging:{ANSI_END}
3664
+ log start - Enable packet logging
3665
+ log stop - Disable packet logging
3666
+ log - Dump log file to console
3667
+ log erase - Erase log file
3668
+
3669
+ {ANSI_BGREEN}Configuration (get/set):{ANSI_END}
3670
+ get name - Node name
3671
+ get radio - Radio params (freq,bw,sf,cr)
3672
+ get tx - TX power (dBm)
3673
+ get repeat - Repeat mode on/off
3674
+ get public.key - Node public key
3675
+ get advert.interval - Advertisement interval (minutes)
3676
+
3677
+ set name <name> - Set node name
3678
+ set tx <power> - Set TX power (dBm)
3679
+ set repeat on|off - Enable/disable repeating
3680
+ set radio f,bw,sf,cr - Set radio params (reboot to apply)
3681
+ set advert.interval <min> - Set advert interval (60-240 min)
3682
+
3683
+ {ANSI_BGREEN}System:{ANSI_END}
3684
+ reboot - Reboot device
3685
+ erase - Erase filesystem (serial only)
3686
+
3687
+ {ANSI_BYELLOW}Type 'quit' or 'q' to exit, Ctrl+C to abort{ANSI_END}
3688
+ """
3689
+
3690
+ async def repeater_loop(port, baudrate):
3691
+ """Interactive loop for repeater text CLI (raw serial commands)"""
3692
+ import serial as pyserial
3693
+
3694
+ print(f"{ANSI_BCYAN}Connecting to repeater at {port} ({baudrate} baud)...{ANSI_END}")
3695
+ try:
3696
+ ser = pyserial.Serial(port, baudrate, timeout=1)
3697
+ except PermissionError:
3698
+ print(f"{ANSI_BRED}Error: Permission denied. Try running with sudo or add user to dialout group.{ANSI_END}")
3699
+ return
3700
+ except Exception as e:
3701
+ print(f"{ANSI_BRED}Error opening serial port: {e}{ANSI_END}")
3702
+ return
3703
+
3704
+ await asyncio.sleep(0.5) # Wait for connection to stabilize
3705
+ ser.reset_input_buffer()
3706
+
3707
+ # Send initial CR to wake up CLI
3708
+ ser.write(b"\r")
3709
+ await asyncio.sleep(0.2)
3710
+ ser.reset_input_buffer()
3711
+
3712
+ # Try to get device info
3713
+ ser.write(b"ver\r")
3714
+ await asyncio.sleep(0.3)
3715
+ ver_response = ser.read(ser.in_waiting or 256).decode(errors='ignore').strip()
3716
+ device_name = "Repeater"
3717
+ for line in ver_response.split('\n'):
3718
+ line = line.strip()
3719
+ if line and not line.startswith("ver") and ">" not in line[:3]:
3720
+ device_name = line.split('(')[0].strip() if '(' in line else line
3721
+ break
3722
+
3723
+ print(f"{ANSI_BGREEN}Connected!{ANSI_END} Device: {ANSI_BMAGENTA}{device_name}{ANSI_END}")
3724
+ print(f"Type {ANSI_BCYAN}help{ANSI_END} for commands, {ANSI_BCYAN}quit{ANSI_END} to exit, {ANSI_BCYAN}Tab{ANSI_END} for completion")
3725
+ print("-" * 50)
3726
+
3727
+ # Setup history and session
3728
+ try:
3729
+ if os.path.isdir(MCCLI_CONFIG_DIR):
3730
+ our_history = FileHistory(MCCLI_REPEATER_HISTORY_FILE)
3731
+ else:
3732
+ our_history = None
3733
+ except Exception:
3734
+ our_history = None
3735
+
3736
+ session = PromptSession(
3737
+ history=our_history,
3738
+ wrap_lines=False,
3739
+ mouse_support=False,
3740
+ complete_style=CompleteStyle.MULTI_COLUMN
3741
+ )
3742
+
3743
+ # Setup key bindings
3744
+ bindings = KeyBindings()
3745
+
3746
+ @bindings.add("escape")
3747
+ def _(event):
3748
+ event.app.current_buffer.cancel_completion()
3749
+
3750
+ # Build prompt
3751
+ prompt_base = f"{ANSI_BGRAY}{device_name}{ANSI_MAGENTA}>{ANSI_END} "
3752
+
3753
+ # Setup completer
3754
+ completer = NestedCompleter.from_nested_dict(REPEATER_COMMANDS)
3755
+
3756
+ while True:
3757
+ try:
3758
+ cmd = await session.prompt_async(
3759
+ ANSI(prompt_base),
3760
+ completer=completer,
3761
+ complete_while_typing=False,
3762
+ key_bindings=bindings
3763
+ )
3764
+ except (KeyboardInterrupt, EOFError):
3765
+ break
3766
+
3767
+ cmd = cmd.strip()
3768
+
3769
+ if not cmd:
3770
+ continue
3771
+
3772
+ if cmd.lower() in ("quit", "exit", "q"):
3773
+ break
3774
+
3775
+ if cmd.lower() == "help":
3776
+ print(REPEATER_HELP)
3777
+ continue
3778
+
3779
+ # Send command with CR terminator
3780
+ ser.write(f"{cmd}\r".encode())
3781
+ await asyncio.sleep(0.3)
3782
+
3783
+ # Read response
3784
+ response = ser.read(ser.in_waiting or 4096).decode(errors='ignore')
3785
+ if response:
3786
+ # Clean up echo and format response
3787
+ lines = response.strip().split('\n')
3788
+ for line in lines:
3789
+ line = line.strip()
3790
+ if line and line != cmd: # Skip echo of command
3791
+ # Color code certain responses
3792
+ if line.startswith("OK") or line.startswith("ok"):
3793
+ print(f"{ANSI_GREEN}{line}{ANSI_END}")
3794
+ elif line.startswith("Error") or line.startswith("ERR"):
3795
+ print(f"{ANSI_RED}{line}{ANSI_END}")
3796
+ elif line.startswith("->"):
3797
+ print(f"{ANSI_CYAN}{line}{ANSI_END}")
3798
+ else:
3799
+ print(line)
3800
+
3801
+ ser.close()
3802
+ print(f"\n{ANSI_BGRAY}Disconnected from repeater.{ANSI_END}")
3803
+
3570
3804
  async def main(argv):
3571
3805
  """ Do the job """
3572
3806
  json_output = JSON
@@ -3577,6 +3811,7 @@ async def main(argv):
3577
3811
  hostname = None
3578
3812
  serial_port = None
3579
3813
  baudrate = 115200
3814
+ repeater_mode = False
3580
3815
  timeout = 2
3581
3816
  pin = None
3582
3817
  first_device = False
@@ -3587,7 +3822,7 @@ async def main(argv):
3587
3822
  address = f.readline().strip()
3588
3823
 
3589
3824
  try:
3590
- opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:Pc:C")
3825
+ opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:Pc:Cr")
3591
3826
  except getopt.GetoptError:
3592
3827
  print("Unrecognized option, use -h to get more help")
3593
3828
  command_usage()
@@ -3599,6 +3834,8 @@ async def main(argv):
3599
3834
  process_event_message.color = False
3600
3835
  case "-C":
3601
3836
  interactive_loop.classic = not interactive_loop.classic
3837
+ case "-r": # repeater mode (raw text CLI)
3838
+ repeater_mode = True
3602
3839
  case "-d" : # name specified on cmdline
3603
3840
  address = arg
3604
3841
  case "-a" : # address specified on cmdline
@@ -3686,6 +3923,15 @@ async def main(argv):
3686
3923
  elif (json_output) :
3687
3924
  logger.setLevel(logging.ERROR)
3688
3925
 
3926
+ # Repeater mode - raw text CLI over serial
3927
+ if repeater_mode:
3928
+ if serial_port is None:
3929
+ print("Error: Repeater mode (-r) requires serial port (-s)")
3930
+ command_usage()
3931
+ return
3932
+ await repeater_loop(serial_port, baudrate)
3933
+ return
3934
+
3689
3935
  mc = None
3690
3936
  if not hostname is None : # connect via tcp
3691
3937
  mc = await MeshCore.create_tcp(host=hostname, port=port, debug=debug, only_error=json_output)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcore-cli
3
- Version: 1.3.11
3
+ Version: 1.3.13
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
@@ -21,6 +21,18 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  meshcore-cli : CLI interface to MeschCore companion app over BLE, TCP or Serial
23
23
 
24
+ ## About
25
+
26
+ meshcore-cli is a tool that connects to your companion radio node (meshcore client) over BLE, TCP or Serial and lets you interact with it from a terminal using a command line interface.
27
+
28
+ You can send commands as parameters to the meshcore-cli command (from your shell) either interactively or through a script.
29
+
30
+ There is also an interactive mode (this is the default when no command is passed). In interactive mode you can enter a contact (another client a repeater, a sensor or a room) and interact with it. For clients, interaction consists in sending/receiving messages. For repeaters, rooms or sensors it will directly give you the remote cli (you can still send messages to rooms using double quote prefix or msg command).
31
+
32
+ Note that meshcore-cli only interacts with companion radios (through BLE, Serial or TCP), you can't connect to a repeater using its serial interface.
33
+
34
+ Also, most meshcore companions only have one interface compiled in at a time. So you can't connect via Serial to a node, which has been compiled as a BLE companion.
35
+
24
36
  ## Install
25
37
 
26
38
  Meshcore-cli depends on the [python meshcore](https://github.com/fdlamotte/meshcore_py) package. You can install both via `pip` or `pipx` using the command:
@@ -0,0 +1,8 @@
1
+ meshcore_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ meshcore_cli/__main__.py,sha256=PfYgibmu2LEtC-OV7L1UgmvV3swJ5rQ4bbXHlwUFlgE,83
3
+ meshcore_cli/meshcore_cli.py,sha256=J1etscwt3J88UzzeJZ9XvghQYwsZWL6k95P87PhAsSE,168812
4
+ meshcore_cli-1.3.13.dist-info/METADATA,sha256=J967l02FCI3aD51Hb0AjWVNfHUS__jdStVIINvgeRrQ,18194
5
+ meshcore_cli-1.3.13.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ meshcore_cli-1.3.13.dist-info/entry_points.txt,sha256=77V29Pyth11GteDk7tneBN3MMk8JI7bTlS-BGSmxCmI,103
7
+ meshcore_cli-1.3.13.dist-info/licenses/LICENSE,sha256=F9s987VtS0AKxW7LdB2EkLMkrdeERI7ICdLJR60A9M4,1066
8
+ meshcore_cli-1.3.13.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- meshcore_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- meshcore_cli/__main__.py,sha256=PfYgibmu2LEtC-OV7L1UgmvV3swJ5rQ4bbXHlwUFlgE,83
3
- meshcore_cli/meshcore_cli.py,sha256=ewDxGrG0U-5gT-SdTaKn-8tYzf0bzfrrqX-C47sk4Hs,160030
4
- meshcore_cli-1.3.11.dist-info/METADATA,sha256=RaYfgG524SLlrIf2NBUwXP6npQgmzGp1edhUjo4aWPM,17138
5
- meshcore_cli-1.3.11.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
- meshcore_cli-1.3.11.dist-info/entry_points.txt,sha256=77V29Pyth11GteDk7tneBN3MMk8JI7bTlS-BGSmxCmI,103
7
- meshcore_cli-1.3.11.dist-info/licenses/LICENSE,sha256=F9s987VtS0AKxW7LdB2EkLMkrdeERI7ICdLJR60A9M4,1066
8
- meshcore_cli-1.3.11.dist-info/RECORD,,