meshcore-cli 1.2.12__py3-none-any.whl → 1.3.6__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.2.12"
35
+ VERSION = "v1.3.6"
36
36
 
37
37
  # default ble address is stored in a config file
38
38
  MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/"
@@ -40,6 +40,10 @@ MCCLI_ADDRESS = MCCLI_CONFIG_DIR + "default_address"
40
40
  MCCLI_HISTORY_FILE = MCCLI_CONFIG_DIR + "history"
41
41
  MCCLI_INIT_SCRIPT = MCCLI_CONFIG_DIR + "init"
42
42
 
43
+ PAYLOAD_TYPENAMES = ["REQ", "RESPONSE", "TEXT_MSG", "ACK", "ADVERT", "GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH", "TRACE", "MULTIPART", "CONTROL"]
44
+ ROUTE_TYPENAMES = ["TC_FLOOD", "FLOOD", "DIRECT", "TC_DIRECT"]
45
+ CONTACT_TYPENAMES = ["NONE","CLI","REP","ROOM","SENS"]
46
+
43
47
  # Fallback address if config file not found
44
48
  # if None or "" then a scan is performed
45
49
  ADDRESS = ""
@@ -76,13 +80,11 @@ ANSI_YELLOW = "\033[0;33m"
76
80
  ANSI_BYELLOW = "\033[1;33m"
77
81
 
78
82
  #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 = " "
83
+ # some possible symbols for prompts 🭬🬛🬗🭬🬛🬃🬗🭬🬛🬃🬗🬏🭀🭋🭨🮋
84
+ ARROW_HEAD = ""
85
+ SLASH_END = ""
86
+ SLASH_START = ""
87
+ INVERT_SLASH = False
86
88
 
87
89
  def escape_ansi(line):
88
90
  ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
@@ -207,27 +209,57 @@ process_event_message.last_node=None
207
209
 
208
210
  async def handle_log_rx(event):
209
211
  mc = handle_log_rx.mc
210
- if handle_log_rx.json_log_rx: # json mode ... raw dump
211
- msg = json.dumps(event.payload)
212
- if handle_message.above:
213
- print_above(msg)
214
- else :
215
- print(msg)
216
- return
217
212
 
218
213
  pkt = bytes().fromhex(event.payload["payload"])
219
214
  pbuf = io.BytesIO(pkt)
220
215
  header = pbuf.read(1)[0]
216
+ route_type = header & 0x03
217
+ payload_type = (header & 0x3c) >> 2
218
+ payload_ver = (header & 0xc0) >> 6
219
+
220
+ transport_code = None
221
+ if route_type == 0x00 or route_type == 0x03: # has transport code
222
+ transport_code = pbuf.read(4) # discard transport code
221
223
 
222
- if header & ~1 == 0x14: # flood msg / channel
224
+ path_len = pbuf.read(1)[0]
225
+ path = pbuf.read(path_len).hex() # Beware of traces where pathes are mixed
226
+
227
+ try :
228
+ route_typename = ROUTE_TYPENAMES[route_type]
229
+ except IndexError:
230
+ logger.debug(f"Unknown route type {route_type}")
231
+ route_typename = "UNK"
232
+
233
+ try :
234
+ payload_typename = PAYLOAD_TYPENAMES[payload_type]
235
+ except IndexError:
236
+ logger.debug(f"Unknown payload type {payload_type}")
237
+ payload_typename = "UNK"
238
+
239
+ pkt_payload = pbuf.read()
240
+
241
+ event.payload["header"] = header
242
+ event.payload["route_type"] = route_type
243
+ event.payload["route_typename"] = route_typename
244
+ event.payload["payload_type"] = payload_type
245
+ event.payload["payload_typename"]= payload_typename
246
+
247
+ event.payload["payload_ver"] = payload_ver
248
+
249
+ if not transport_code is None:
250
+ event.payload["transport_code"] = transport_code.hex()
251
+
252
+ event.payload["path_len"] = path_len
253
+ event.payload["path"] = path
254
+
255
+ event.payload["pkt_payload"] = pkt_payload.hex()
256
+
257
+ if payload_type == 0x05: # flood msg / channel
223
258
  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
259
+ pk_buf = io.BytesIO(pkt_payload)
260
+ chan_hash = pk_buf.read(1).hex()
261
+ cipher_mac = pk_buf.read(2)
262
+ msg = pk_buf.read() # until the end of buffer
231
263
 
232
264
  channel = None
233
265
  for c in await get_channels(mc):
@@ -253,13 +285,21 @@ async def handle_log_rx(event):
253
285
  if chan_name != "" :
254
286
  width = os.get_terminal_size().columns
255
287
  cars = width - 13 - 2 * path_len - len(chan_name) - 1
256
- dispmsg = message[0:cars]
288
+ dispmsg = message.replace("\n","")[0:cars]
257
289
  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}"
258
290
  if handle_message.above:
259
291
  print_above(txt)
260
292
  else:
261
293
  print(txt)
262
294
 
295
+ if handle_log_rx.json_log_rx: # json mode ... raw dump
296
+ msg = json.dumps(event.payload)
297
+ if handle_message.above:
298
+ print_above(msg)
299
+ else :
300
+ print(msg)
301
+
302
+
263
303
  handle_log_rx.json_log_rx = False
264
304
  handle_log_rx.channel_echoes = False
265
305
  handle_log_rx.mc = None
@@ -392,7 +432,7 @@ class MyNestedCompleter(NestedCompleter):
392
432
  opts = self.options.keys()
393
433
  completer = WordCompleter(
394
434
  opts, ignore_case=self.ignore_case,
395
- pattern=re.compile(r"([a-zA-Z0-9_\\/\#]+|[^a-zA-Z0-9_\s\#]+)"))
435
+ pattern=re.compile(r"([a-zA-Z0-9_\\/\#\?]+|[^a-zA-Z0-9_\s\#\?]+)"))
396
436
  yield from completer.get_completions(document, complete_event)
397
437
  else: # normal behavior for remainder
398
438
  yield from super().get_completions(document, complete_event)
@@ -470,7 +510,7 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
470
510
  "login" : contact_list,
471
511
  "cmd" : contact_list,
472
512
  "req_status" : contact_list,
473
- "req_bstatus" : contact_list,
513
+ "req_neighbours": contact_list,
474
514
  "logout" : contact_list,
475
515
  "req_telemetry" : contact_list,
476
516
  "req_binary" : contact_list,
@@ -495,7 +535,6 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
495
535
  "print_snr" : {"on":None, "off": None},
496
536
  "json_msgs" : {"on":None, "off": None},
497
537
  "color" : {"on":None, "off":None},
498
- "print_name" : {"on":None, "off":None},
499
538
  "print_adverts" : {"on":None, "off":None},
500
539
  "json_log_rx" : {"on":None, "off":None},
501
540
  "channel_echoes" : {"on":None, "off":None},
@@ -525,7 +564,6 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
525
564
  "print_snr":None,
526
565
  "json_msgs":None,
527
566
  "color":None,
528
- "print_name":None,
529
567
  "print_adverts":None,
530
568
  "json_log_rx":None,
531
569
  "channel_echoes":None,
@@ -545,11 +583,24 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
545
583
  "flood_after":None,
546
584
  "custom":None,
547
585
  },
586
+ "?get":None,
587
+ "?set":None,
588
+ "?scope":None,
589
+ "?contact_info":None,
590
+ "?apply_to":None,
591
+ "?at":None,
592
+ "?node_discover":None,
593
+ "?nd":None,
594
+ "?pending_contacts":None,
595
+ "?add_pending":None,
596
+ "?flush_pending":None,
548
597
  }
549
598
 
550
599
  contact_completion_list = {
551
600
  "contact_info": None,
552
601
  "contact_name": None,
602
+ "contact_key": None,
603
+ "contact_type": None,
553
604
  "contact_lastmod": None,
554
605
  "export_contact" : None,
555
606
  "share_contact" : None,
@@ -577,7 +628,6 @@ def make_completion_dict(contacts, pending={}, to=None, channels=None):
577
628
  "login" : None,
578
629
  "logout" : None,
579
630
  "req_status" : None,
580
- "req_bstatus" : None,
581
631
  "req_neighbours": None,
582
632
  "cmd" : None,
583
633
  "ver" : None,
@@ -713,9 +763,10 @@ make_completion_dict.custom_vars = {}
713
763
  async def interactive_loop(mc, to=None) :
714
764
  print("""Interactive mode, most commands from terminal chat should work.
715
765
  Use \"to\" to select recipient, use Tab to complete name ...
716
- Line starting with \"$\" or \".\" will issue a meshcli command.
766
+ Some cmds have an help accessible with ?<cmd>. Do ?[Tab] to get a list.
717
767
  \"quit\", \"q\", CTRL+D will end interactive mode""")
718
768
 
769
+
719
770
  contact = to
720
771
  prev_contact = None
721
772
 
@@ -724,16 +775,16 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
724
775
 
725
776
  await get_contacts(mc, anim=True)
726
777
  await get_channels(mc, anim=True)
778
+
779
+ # Call sync_msg before going further so there is no issue when scrolling
780
+ # long list of msgs
781
+ await next_cmd(mc, ["sync_msgs"])
782
+
727
783
  await subscribe_to_msgs(mc, above=True)
728
784
 
729
785
  handle_new_contact.print_new_contacts = True
730
786
 
731
787
  try:
732
- while True: # purge msgs
733
- res = await mc.commands.get_msg()
734
- if res.type == EventType.NO_MORE_MSGS:
735
- break
736
-
737
788
  if os.path.isdir(MCCLI_CONFIG_DIR) :
738
789
  our_history = FileHistory(MCCLI_HISTORY_FILE)
739
790
  else:
@@ -765,26 +816,32 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
765
816
 
766
817
  color = process_event_message.color
767
818
  classic = interactive_loop.classic or not color
768
- print_name = interactive_loop.print_name
769
819
 
770
820
  if classic:
771
821
  prompt = ""
772
822
  else:
773
823
  prompt = f"{ANSI_INVERT}"
774
824
 
775
- if print_name or contact is None :
776
- if color:
777
- prompt = prompt + f"{ANSI_BGRAY}"
778
- prompt = prompt + f"{mc.self_info['name']}"
779
- if contact is None: # display scope
780
- if not scope is None:
781
- prompt = prompt + f"|{scope}"
825
+ prompt = prompt + f"{ANSI_BGRAY}"
826
+ prompt = prompt + f"{mc.self_info['name']}"
827
+ if contact is None: # display scope
828
+ if not scope is None:
829
+ prompt = prompt + f"|{scope}"
830
+
831
+ if contact is None :
782
832
  if classic :
783
- prompt = prompt + "> "
833
+ prompt = prompt + ">"
784
834
  else :
785
- prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}{ANSI_INVERT}"
786
-
787
- if not contact is None :
835
+ prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}"
836
+ else:
837
+ if classic :
838
+ prompt = prompt + "/"
839
+ else :
840
+ if INVERT_SLASH:
841
+ prompt = prompt + f"{ANSI_INVERT}"
842
+ else:
843
+ prompt = prompt + f"{ANSI_NORMAL}"
844
+ prompt = prompt + f"{SLASH_START}"
788
845
  if not last_ack:
789
846
  prompt = prompt + f"{ANSI_BRED}"
790
847
  if classic :
@@ -800,11 +857,9 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
800
857
  else :
801
858
  prompt = prompt + f"{ANSI_BBLUE}"
802
859
  if not classic:
860
+ prompt = prompt + f"{SLASH_END}"
803
861
  prompt = prompt + f"{ANSI_INVERT}"
804
862
 
805
- if print_name and not classic :
806
- prompt = prompt + f"{ANSI_NORMAL}{ARROW_TAIL}{ANSI_INVERT}"
807
-
808
863
  prompt = prompt + f"{contact['adv_name']}"
809
864
  if contact["type"] == 0 or contact["out_path_len"]==-1:
810
865
  if scope is None:
@@ -818,14 +873,15 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
818
873
  prompt = prompt + "|" + contact["out_path"]
819
874
 
820
875
  if classic :
821
- prompt = prompt + f"{ANSI_NORMAL}> "
876
+ prompt = prompt + f"{ANSI_NORMAL}>"
822
877
  else:
823
878
  prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}"
824
879
 
825
880
  prompt = prompt + f"{ANSI_END}"
826
881
 
827
- if not color :
828
- prompt=escape_ansi(prompt)
882
+ prompt = prompt + " "
883
+ if not color :
884
+ prompt=escape_ansi(prompt)
829
885
 
830
886
  session.app.ttimeoutlen = 0.2
831
887
  session.app.timeoutlen = 0.2
@@ -1036,8 +1092,10 @@ Line starting with \"$\" or \".\" will issue a meshcli command.
1036
1092
  except asyncio.CancelledError:
1037
1093
  # Handle task cancellation from KeyboardInterrupt in asyncio.run()
1038
1094
  print("Exiting cli")
1039
- interactive_loop.classic = False
1040
- interactive_loop.print_name = True
1095
+ if platform.system() == "Darwin" or platform.system() == "Windows":
1096
+ interactive_loop.classic = True
1097
+ else:
1098
+ interactive_loop.classic = False
1041
1099
 
1042
1100
  async def process_contact_chat_line(mc, contact, line):
1043
1101
  if contact["type"] == 0:
@@ -1059,6 +1117,26 @@ async def process_contact_chat_line(mc, contact, line):
1059
1117
  await process_cmds(mc, args)
1060
1118
  return True
1061
1119
 
1120
+ if line.startswith("contact_key") or line.startswith("ck"):
1121
+ print(contact['public_key'],end="")
1122
+ if " " in line:
1123
+ print(" ", end="", flush=True)
1124
+ secline = line.split(" ", 1)[1]
1125
+ await process_contact_chat_line(mc, contact, secline)
1126
+ else:
1127
+ print("")
1128
+ return True
1129
+
1130
+ if line.startswith("contact_type") or line.startswith("ct"):
1131
+ print(f"{CONTACT_TYPENAMES[contact['type']]:4}",end="")
1132
+ if " " in line:
1133
+ print(" ", end="", flush=True)
1134
+ secline = line.split(" ", 1)[1]
1135
+ await process_contact_chat_line(mc, contact, secline)
1136
+ else:
1137
+ print("")
1138
+ return True
1139
+
1062
1140
  if line.startswith("contact_name") or line.startswith("cn"):
1063
1141
  print(contact['adv_name'],end="")
1064
1142
  if " " in line:
@@ -1069,6 +1147,44 @@ async def process_contact_chat_line(mc, contact, line):
1069
1147
  print("")
1070
1148
  return True
1071
1149
 
1150
+ if line.startswith("path") :
1151
+ if contact['out_path_len'] == -1:
1152
+ print("Flood", end="")
1153
+ elif contact['out_path_len'] == 0:
1154
+ print("0 hop", end="")
1155
+ else:
1156
+ print(contact['out_path'],end="")
1157
+ if " " in line:
1158
+ print(" ", end="", flush=True)
1159
+ secline = line.split(" ", 1)[1]
1160
+ await process_contact_chat_line(mc, contact, secline)
1161
+ else:
1162
+ print("")
1163
+ return True
1164
+
1165
+ if line.startswith("sleep ") or line.startswith("s "):
1166
+ try:
1167
+ sleeptime = int(line.split(" ",2)[1])
1168
+ cmd_pos = 2
1169
+ except IndexError: # nothing arg after sleep
1170
+ sleeptime = 1
1171
+ cmd_pos = 0
1172
+ except ValueError:
1173
+ sleeptime = 1
1174
+ cmd_pos = 1
1175
+
1176
+ try:
1177
+ if cmd_pos > 0:
1178
+ secline = line.split(" ",cmd_pos)[cmd_pos]
1179
+ await process_contact_chat_line(mc, contact, secline)
1180
+ except IndexError:
1181
+ pass
1182
+
1183
+ # will sleep after executed command if there is a command
1184
+ await asyncio.sleep(sleeptime)
1185
+
1186
+ return True
1187
+
1072
1188
  if line == "contact_lastmod":
1073
1189
  timestamp = contact["lastmod"]
1074
1190
  print(f"{contact['adv_name']} updated"
@@ -1077,21 +1193,24 @@ async def process_contact_chat_line(mc, contact, line):
1077
1193
  return True
1078
1194
 
1079
1195
  # commands that take contact as second arg will be sent to recipient
1080
- if line == "sc" or line == "share_contact" or\
1081
- line == "ec" or line == "export_contact" or\
1082
- line == "uc" or line == "upload_contact" or\
1083
- line == "rp" or line == "reset_path" or\
1084
- line == "dp" or line == "disc_path" or\
1085
- line == "contact_info" or line == "ci" or\
1086
- line == "req_status" or line == "rs" or\
1087
- line == "req_neighbours" or line == "rn" or\
1088
- line == "req_bstatus" or line == "rbs" or\
1089
- line == "req_telemetry" or line == "rt" or\
1090
- line == "req_acl" or\
1091
- line == "path" or\
1092
- line == "logout" :
1093
- args = [line, contact['adv_name']]
1196
+ # and can be chained ...
1197
+ if line.startswith("sc") or line.startswith("share_contact") or\
1198
+ line.startswith("ec") or line.startswith("export_contact") or\
1199
+ line.startswith("uc") or line.startswith("upload_contact") or\
1200
+ line.startswith("rp") or line.startswith("reset_path") or\
1201
+ line.startswith("dp") or line.startswith("disc_path") or\
1202
+ line.startswith("contact_info") or line.startswith("ci") or\
1203
+ line.startswith("req_status") or line.startswith("rs") or\
1204
+ line.startswith("req_neighbours") or line.startswith("rn") or\
1205
+ line.startswith("req_telemetry") or line.startswith("rt") or\
1206
+ line.startswith("req_acl") or\
1207
+ line.startswith("path") or\
1208
+ line.startswith("logout") :
1209
+ args = [line.split()[0], contact['adv_name']]
1094
1210
  await process_cmds(mc, args)
1211
+ if " " in line:
1212
+ secline = line.split(" ", 1)[1]
1213
+ await process_contact_chat_line(mc, contact, secline)
1095
1214
  return True
1096
1215
 
1097
1216
  # special case for rp that can be chained from cmdline
@@ -1104,6 +1223,8 @@ async def process_contact_chat_line(mc, contact, line):
1104
1223
 
1105
1224
  if line.startswith("set timeout "):
1106
1225
  cmds=line.split(" ")
1226
+ #args = ["contact_timeout", contact['adv_name'], cmds[2]]
1227
+ #await process_cmds(mc, args)
1107
1228
  contact["timeout"] = float(cmds[2])
1108
1229
  return True
1109
1230
 
@@ -1241,12 +1362,13 @@ async def process_contact_chat_line(mc, contact, line):
1241
1362
 
1242
1363
  return False
1243
1364
 
1244
- async def apply_command_to_contacts(mc, contact_filter, line):
1365
+ async def apply_command_to_contacts(mc, contact_filter, line, json_output=False):
1245
1366
  upd_before = None
1246
1367
  upd_after = None
1247
1368
  contact_type = None
1248
1369
  min_hops = None
1249
1370
  max_hops = None
1371
+ count = 0
1250
1372
 
1251
1373
  await mc.ensure_contacts()
1252
1374
 
@@ -1301,6 +1423,9 @@ async def apply_command_to_contacts(mc, contact_filter, line):
1301
1423
  (upd_after is None or contact["lastmod"] > upd_after) and\
1302
1424
  (min_hops is None or contact["out_path_len"] >= min_hops) and\
1303
1425
  (max_hops is None or contact["out_path_len"] <= max_hops):
1426
+
1427
+ count = count + 1
1428
+
1304
1429
  if await process_contact_chat_line(mc, contact, line):
1305
1430
  pass
1306
1431
 
@@ -1325,6 +1450,9 @@ async def apply_command_to_contacts(mc, contact_filter, line):
1325
1450
  else:
1326
1451
  logger.error(f"Can't send {line} to {contact['adv_name']}")
1327
1452
 
1453
+ if not json_output:
1454
+ print(f"> {count} matches in contacts")
1455
+
1328
1456
  async def send_cmd (mc, contact, cmd) :
1329
1457
  res = await mc.commands.send_cmd(contact, cmd)
1330
1458
  if not res is None and not res.type == EventType.ERROR:
@@ -1366,7 +1494,8 @@ async def send_msg (mc, contact, msg) :
1366
1494
  return res
1367
1495
 
1368
1496
  async def msg_ack (mc, contact, msg) :
1369
- timeout = 0 if not 'timeout' in contact else contact['timeout']
1497
+ timeout = 0 if not isinstance(contact, dict) or not 'timeout' in contact\
1498
+ else contact['timeout']
1370
1499
  res = await mc.commands.send_msg_with_retry(contact, msg,
1371
1500
  max_attempts=msg_ack.max_attempts,
1372
1501
  flood_after=msg_ack.flood_after,
@@ -1615,7 +1744,7 @@ async def print_disc_trace_to (mc, contact):
1615
1744
 
1616
1745
  async def next_cmd(mc, cmds, json_output=False):
1617
1746
  """ process next command """
1618
- global ARROW_TAIL, ARROW_HEAD
1747
+ global ARROW_HEAD, SLASH_START, SLASH_END, INVERT_SLASH
1619
1748
  try :
1620
1749
  argnum = 0
1621
1750
 
@@ -1715,44 +1844,32 @@ async def next_cmd(mc, cmds, json_output=False):
1715
1844
 
1716
1845
  case "apply_to"|"at":
1717
1846
  argnum = 2
1718
- await apply_command_to_contacts(mc, cmds[1], cmds[2])
1847
+ await apply_command_to_contacts(mc, cmds[1], cmds[2], json_output=json_output)
1719
1848
 
1720
1849
  case "set":
1721
1850
  argnum = 2
1722
1851
  match cmds[1]:
1723
1852
  case "help" :
1724
1853
  argnum = 1
1725
- print("""Available parameters :
1726
- pin <pin> : ble pin
1727
- radio <freq,bw,sf,cr> : radio params
1728
- tuning <rx_dly,af> : tuning params
1729
- tx <dbm> : tx power
1730
- name <name> : node name
1731
- lat <lat> : latitude
1732
- lon <lon> : longitude
1733
- coords <lat,lon> : coordinates
1734
- print_snr <on/off> : toggle snr display in messages
1735
- print_adverts <on/off> : display adverts as they come
1736
- print_new_contacts <on/off> : display new pending contacts when available
1737
- print_path_updates <on/off> : display path updates as they come""")
1854
+ get_help_for("set")
1738
1855
  case "max_flood_attempts":
1739
1856
  msg_ack.max_flood_attempts=int(cmds[2])
1740
1857
  case "max_attempts":
1741
1858
  msg_ack.max_attempts=int(cmds[2])
1742
1859
  case "flood_after":
1743
1860
  msg_ack.flood_after=int(cmds[2])
1744
- case "print_name":
1745
- interactive_loop.print_name = (cmds[2] == "on")
1746
- if json_output :
1747
- print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1748
1861
  case "classic_prompt":
1749
1862
  interactive_loop.classic = (cmds[2] == "on")
1750
1863
  if json_output :
1751
1864
  print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]}))
1752
- case "arrow_tail":
1753
- ARROW_TAIL = cmds[2]
1754
1865
  case "arrow_head":
1755
1866
  ARROW_HEAD = cmds[2]
1867
+ case "slash_start":
1868
+ SLASH_START = cmds[2]
1869
+ case "slash_end":
1870
+ SLASH_END = cmds[2]
1871
+ case "invert_slash":
1872
+ INVERT_SLASH = cmds[2] == "on"
1756
1873
  case "color" :
1757
1874
  process_event_message.color = (cmds[2] == "on")
1758
1875
  if json_output :
@@ -1958,21 +2075,7 @@ async def next_cmd(mc, cmds, json_output=False):
1958
2075
  argnum = 1
1959
2076
  match cmds[1]:
1960
2077
  case "help":
1961
- print("""Gets parameters from node
1962
- name : node name
1963
- bat : battery level in mV
1964
- fstats : fs statistics
1965
- coords : adv coordinates
1966
- lat : latitude
1967
- lon : longitude
1968
- radio : radio parameters
1969
- tx : tx power
1970
- print_snr : snr display in messages
1971
- print_adverts : display adverts as they come
1972
- print_new_contacts : display new pending contacts when available
1973
- print_path_updates : display path updates as they come
1974
- custom : all custom variables in json format
1975
- each custom var can also be get/set directly""")
2078
+ get_help_for("get")
1976
2079
  case "max_flood_attempts":
1977
2080
  if json_output :
1978
2081
  print(json.dumps({"max_flood_attempts" : msg_ack.max_flood_attempts}))
@@ -1983,11 +2086,6 @@ async def next_cmd(mc, cmds, json_output=False):
1983
2086
  print(json.dumps({"flood_after" : msg_ack.flood_after}))
1984
2087
  else:
1985
2088
  print(f"flood_after: {msg_ack.flood_after}")
1986
- case "print_name":
1987
- if json_output :
1988
- print(json.dumps({"print_name" : interactive_loop.print_name}))
1989
- else:
1990
- print(f"{'on' if interactive_loop.print_name else 'off'}")
1991
2089
  case "classic_prompt":
1992
2090
  if json_output :
1993
2091
  print(json.dumps({"classic_prompt" : interactive_loop.classic}))
@@ -2237,7 +2335,7 @@ async def next_cmd(mc, cmds, json_output=False):
2237
2335
  argnum = 2
2238
2336
  dest = None
2239
2337
 
2240
- if len(cmds[1]) == 12: # possibly an hex prefix
2338
+ if len(cmds[1]) >= 12: # possibly an hex prefix
2241
2339
  try:
2242
2340
  dest = bytes.fromhex(cmds[1])
2243
2341
  except ValueError:
@@ -2344,12 +2442,12 @@ async def next_cmd(mc, cmds, json_output=False):
2344
2442
  else :
2345
2443
  color = process_event_message.color
2346
2444
  classic = interactive_loop.classic or not color
2347
- print("]",end="")
2445
+ print(" ", end="")
2348
2446
  for t in ev.payload["path"]:
2349
2447
  if classic :
2350
2448
  print("→",end="")
2351
2449
  else:
2352
- print(f" {ANSI_INVERT}", end="")
2450
+ print(f"{ANSI_INVERT}", end="")
2353
2451
  snr = t['snr']
2354
2452
  if color:
2355
2453
  if snr >= 10 :
@@ -2368,7 +2466,7 @@ async def next_cmd(mc, cmds, json_output=False):
2368
2466
  if "hash" in t:
2369
2467
  print(f"[{t['hash']}]",end="")
2370
2468
  else:
2371
- print("[")
2469
+ print()
2372
2470
 
2373
2471
  case "login" | "l" :
2374
2472
  argnum = 2
@@ -2428,46 +2526,6 @@ async def next_cmd(mc, cmds, json_output=False):
2428
2526
  contact = mc.get_contact_by_name(cmds[1])
2429
2527
  contact["timeout"] = float(cmds[2])
2430
2528
 
2431
- case "req_status" | "rs" :
2432
- argnum = 1
2433
- await mc.ensure_contacts()
2434
- contact = mc.get_contact_by_name(cmds[1])
2435
- res = await mc.commands.send_statusreq(contact)
2436
- logger.debug(res)
2437
- if res.type == EventType.ERROR:
2438
- print(f"Error while requesting status: {res}")
2439
- else :
2440
- timeout = res.payload["suggested_timeout"]/800 if not "timeout" in contact or contact['timeout']==0 else contact["timeout"]
2441
- res = await mc.wait_for_event(EventType.STATUS_RESPONSE, timeout=timeout)
2442
- logger.debug(res)
2443
- if res is None:
2444
- if json_output :
2445
- print(json.dumps({"error" : "Timeout waiting status"}))
2446
- else:
2447
- print("Timeout waiting status")
2448
- else :
2449
- print(json.dumps(res.payload, indent=4))
2450
-
2451
- case "req_telemetry" | "rt" :
2452
- argnum = 1
2453
- await mc.ensure_contacts()
2454
- contact = mc.get_contact_by_name(cmds[1])
2455
- res = await mc.commands.send_telemetry_req(contact)
2456
- logger.debug(res)
2457
- if res.type == EventType.ERROR:
2458
- print(f"Error while requesting telemetry")
2459
- else:
2460
- timeout = res.payload["suggested_timeout"]/800 if not "timeout" in contact or contact['timeout']==0 else contact["timeout"]
2461
- res = await mc.wait_for_event(EventType.TELEMETRY_RESPONSE, timeout=timeout)
2462
- logger.debug(res)
2463
- if res is None:
2464
- if json_output :
2465
- print(json.dumps({"error" : "Timeout waiting telemetry"}))
2466
- else:
2467
- print("Timeout waiting telemetry")
2468
- else :
2469
- print(json.dumps(res.payload, indent=4))
2470
-
2471
2529
  case "disc_path" | "dp" :
2472
2530
  argnum = 1
2473
2531
  await mc.ensure_contacts()
@@ -2538,22 +2596,18 @@ async def next_cmd(mc, cmds, json_output=False):
2538
2596
  await mc.ensure_contacts()
2539
2597
  print(f"Discovered {len(dn)} nodes:")
2540
2598
  for n in dn:
2541
- name = f"{n['pubkey'][0:2]} {mc.get_contact_by_key_prefix(n['pubkey'])['adv_name']}"
2542
- if name is None:
2599
+ try :
2600
+ name = f"{n['pubkey'][0:2]} {mc.get_contact_by_key_prefix(n['pubkey'])['adv_name']}"
2601
+ except TypeError:
2543
2602
  name = n["pubkey"][0:16]
2544
- type = f"t:{n['node_type']}"
2545
- if n['node_type'] == 1:
2546
- type = "CLI"
2547
- elif n['node_type'] == 2:
2548
- type = "REP"
2549
- elif n['node_type'] == 3:
2550
- type = "ROOM"
2551
- elif n['node_type'] == 4:
2552
- type = "SENS"
2553
-
2554
- print(f" {name:16} {type:>4} SNR: {n['SNR_in']:6,.2f}->{n['SNR']:6,.2f} RSSI: ->{n['RSSI']:4}")
2555
-
2556
- case "req_btelemetry"|"rbt" :
2603
+ if n['node_type'] >= len(CONTACT_TYPENAMES):
2604
+ type = f"t:{n['node_type']}"
2605
+ else:
2606
+ type = CONTACT_TYPENAMES[n['node_type']]
2607
+
2608
+ print(f" {name:22} {type:>4} SNR: {n['SNR_in']:6,.2f}->{n['SNR']:6,.2f} RSSI: ->{n['RSSI']:4}")
2609
+
2610
+ case "req_telemetry"|"rt" :
2557
2611
  argnum = 1
2558
2612
  await mc.ensure_contacts()
2559
2613
  contact = mc.get_contact_by_name(cmds[1])
@@ -2565,9 +2619,13 @@ async def next_cmd(mc, cmds, json_output=False):
2565
2619
  else:
2566
2620
  print("Error getting data")
2567
2621
  else :
2568
- print(json.dumps(res))
2622
+ print(json.dumps({
2623
+ "name": contact["adv_name"],
2624
+ "pubkey_pre": contact["public_key"][0:16],
2625
+ "lpp": res,
2626
+ }, indent = 4))
2569
2627
 
2570
- case "req_bstatus"|"rbs" :
2628
+ case "req_status"|"rs" :
2571
2629
  argnum = 1
2572
2630
  await mc.ensure_contacts()
2573
2631
  contact = mc.get_contact_by_name(cmds[1])
@@ -2651,15 +2709,30 @@ async def next_cmd(mc, cmds, json_output=False):
2651
2709
  if json_output:
2652
2710
  print(json.dumps(res, indent=4))
2653
2711
  else:
2712
+ width = os.get_terminal_size().columns
2654
2713
  print(f"Got {res['results_count']} neighbours out of {res['neighbours_count']} from {contact['adv_name']}:")
2655
2714
  for n in res['neighbours']:
2656
2715
  ct = mc.get_contact_by_key_prefix(n["pubkey"])
2657
- if ct :
2716
+ if ct and width > 60 :
2658
2717
  name = f"[{n['pubkey'][0:8]}] {ct['adv_name']}"
2718
+ name = f"{name:30}"
2719
+ elif ct :
2720
+ name = f"{ct['adv_name']}"
2721
+ name = f"{name:20}"
2659
2722
  else:
2660
2723
  name = f"[{n['pubkey']}]"
2661
2724
 
2662
- print(f" {name:30} last viewed {n['secs_ago']} sec ago at {n['snr']} ")
2725
+ t_s = n['secs_ago']
2726
+ time_ago = f"{t_s}s"
2727
+ if t_s / 86400 >= 1 : # result in days
2728
+ time_ago = f"{int(t_s/86400)}d ago{f' ({time_ago})' if width > 62 else ''}"
2729
+ elif t_s / 3600 >= 1 : # result in days
2730
+ time_ago = f"{int(t_s/3600)}h ago{f' ({time_ago})' if width > 62 else ''}"
2731
+ elif t_s / 60 >= 1 : # result in min
2732
+ time_ago = f"{int(t_s/60)}m ago{f' ({time_ago})' if width > 62 else ''}"
2733
+
2734
+
2735
+ print(f" {name} {time_ago}, {n['snr']}dB{' SNR' if width > 66 else ''}")
2663
2736
 
2664
2737
  case "req_binary" :
2665
2738
  argnum = 2
@@ -2682,7 +2755,13 @@ async def next_cmd(mc, cmds, json_output=False):
2682
2755
  print(json.dumps(res, indent=4))
2683
2756
  else :
2684
2757
  for c in res.items():
2685
- print(c[1]["adv_name"])
2758
+ if c[1]['out_path_len'] == -1:
2759
+ path_str = "Flood"
2760
+ elif c[1]['out_path_len'] == 0:
2761
+ path_str = "0 hop"
2762
+ else:
2763
+ path_str = f"{c[1]['out_path']}"
2764
+ print(f"{c[1]['adv_name']:30} {CONTACT_TYPENAMES[c[1]['type']]:4} {c[1]['public_key'][:12]}  {path_str}")
2686
2765
  print(f"> {len(mc.contacts)} contacts in device")
2687
2766
 
2688
2767
  case "reload_contacts" | "rc":
@@ -2750,7 +2829,7 @@ async def next_cmd(mc, cmds, json_output=False):
2750
2829
  if (path_len == 0) :
2751
2830
  print("0 hop")
2752
2831
  elif (path_len == -1) :
2753
- print("Path not set")
2832
+ print("Flood")
2754
2833
  else:
2755
2834
  print(path)
2756
2835
 
@@ -2777,6 +2856,8 @@ async def next_cmd(mc, cmds, json_output=False):
2777
2856
  print(f"Unknown contact {cmds[1]}")
2778
2857
  else:
2779
2858
  path = cmds[2].replace(",","") # we'll accept path with ,
2859
+ if path == "0":
2860
+ path = ""
2780
2861
  try:
2781
2862
  res = await mc.commands.change_contact_path(contact, path)
2782
2863
  logger.debug(res)
@@ -3109,8 +3190,8 @@ def command_help():
3109
3190
  reboot : reboots node
3110
3191
  sleep <secs> : sleeps for a given amount of secs s
3111
3192
  wait_key : wait until user presses <Enter> wk
3112
- apply_to <scope> <cmds>: sends cmds to contacts matching scope at
3113
- Messenging
3193
+ apply_to <f> <cmds> : sends cmds to contacts matching f at
3194
+ Messaging
3114
3195
  msg <name> <msg> : send message to node by name m {
3115
3196
  wait_ack : wait an ack wa }
3116
3197
  chan <nb> <msg> : send message to channel number <nb> ch
@@ -3123,6 +3204,7 @@ def command_help():
3123
3204
  get_channel <n> : get info for channel (by number or name)
3124
3205
  set_channel n nm k : set channel info (nb, name, key)
3125
3206
  remove_channel <n> : remove channel (by number or name)
3207
+ scope <s> : sets scope for flood messages
3126
3208
  Management
3127
3209
  advert : sends advert a
3128
3210
  floodadv : flood advert
@@ -3164,17 +3246,21 @@ def command_help():
3164
3246
  def usage () :
3165
3247
  """ Prints some help """
3166
3248
  version()
3249
+ command_usage()
3250
+ print(" Available Commands and shorcuts (can be chained) :""")
3251
+ command_help()
3252
+
3253
+ def command_usage() :
3167
3254
  print("""
3168
3255
  Usage : meshcore-cli <args> <commands>
3169
3256
 
3170
3257
  Arguments :
3171
- -h : prints this help
3258
+ -h : prints help for arguments and commands
3172
3259
  -v : prints version
3173
3260
  -j : json output (disables init file)
3174
3261
  -D : debug
3175
3262
  -S : scan for devices and show a selector
3176
3263
  -l : list available ble/serial devices and exit
3177
- -c <on/off> : disables most of color output if off
3178
3264
  -T <timeout> : timeout for the ble scan (-S and -l) default 2s
3179
3265
  -a <address> : specifies device address (can be a name)
3180
3266
  -d <name> : filter meshcore devices with name or address
@@ -3183,14 +3269,14 @@ def usage () :
3183
3269
  -p <port> : specifies tcp port (default 5000)
3184
3270
  -s <port> : use serial port <port>
3185
3271
  -b <baudrate> : specify baudrate
3186
-
3187
- Available Commands and shorcuts (can be chained) :""")
3188
- command_help()
3272
+ -C : toggles classic mode for prompt
3273
+ -c <on/off> : disables most of color output if off
3274
+ """)
3189
3275
 
3190
3276
  def get_help_for (cmdname, context="line") :
3191
3277
  if cmdname == "apply_to" or cmdname == "at" :
3192
- print("""apply_to <scope> <cmd> : applies cmd to contacts matching scope
3193
- Scope acts like a filter with comma separated fields :
3278
+ print("""apply_to <f> <cmd> : applies cmd to contacts matching filter <f>
3279
+ Filter is constructed with comma separated fields :
3194
3280
  - u, matches modification time < or > than a timestamp
3195
3281
  (can also be days hours or minutes ago if followed by d,h or m)
3196
3282
  - t, matches the type (1: client, 2: repeater, 3: room, 4: sensor)
@@ -3198,7 +3284,7 @@ def get_help_for (cmdname, context="line") :
3198
3284
  - d, direct, similar to h>-1
3199
3285
  - f, flood, similar to h<0 or h=-1
3200
3286
 
3201
- Note: Some commands like contact_name (aka cn), reset_path (aka rp), forget_password (aka fp) can be chained.
3287
+ Note: Some commands like contact_name (aka cn), contact_key (aka ck), contact_type (aka ct), reset_path (aka rp), forget_password (aka fp) can be chained. There is also a sleep command taking an optional event. The sleep will be issued after the command, it helps limiting rate through repeaters ...
3202
3288
 
3203
3289
  Examples:
3204
3290
  # removes all clients that have not been updated in last 2 days
@@ -3209,7 +3295,7 @@ def get_help_for (cmdname, context="line") :
3209
3295
  at t=2 rp login
3210
3296
  """)
3211
3297
 
3212
- if cmdname == "node_discover" or cmdname == "nd" :
3298
+ elif cmdname == "node_discover" or cmdname == "nd" :
3213
3299
  print("""node_discover <filter> : discovers 0-hop nodes and displays signal info
3214
3300
 
3215
3301
  filter can be "all" for all types or nodes or a comma separated list consisting of :
@@ -3221,6 +3307,98 @@ def get_help_for (cmdname, context="line") :
3221
3307
  nd can be used with no filter parameter ... !!! BEWARE WITH CHAINING !!!
3222
3308
  """)
3223
3309
 
3310
+ elif cmdname == "get" :
3311
+ print("""Gets parameters from node
3312
+ Please see also help for set command, which is more up to date ...
3313
+ name : node name
3314
+ bat : battery level in mV
3315
+ fstats : fs statistics
3316
+ coords : adv coordinates
3317
+ lat : latitude
3318
+ lon : longitude
3319
+ radio : radio parameters
3320
+ tx : tx power
3321
+ print_snr : snr display in messages
3322
+ print_adverts : display adverts as they come
3323
+ print_new_contacts : display new pending contacts when available
3324
+ print_path_updates : display path updates as they come
3325
+ custom : all custom variables in json format
3326
+ each custom var can also be get/set directly
3327
+ """)
3328
+
3329
+ elif cmdname == "set" :
3330
+ print("""Available parameters :
3331
+ device:
3332
+ pin <pin> : ble pin
3333
+ radio <freq,bw,sf,cr> : radio params
3334
+ tuning <rx_dly,af> : tuning params
3335
+ tx <dbm> : tx power
3336
+ name <name> : node name
3337
+ lat <lat> : latitude
3338
+ lon <lon> : longitude
3339
+ coords <lat,lon> : coordinates
3340
+ multi_ack <on/off> : multi-acks feature
3341
+ telemetry_mode_base <mode> : set basic telemetry mode all/selected/off
3342
+ telemetry_mode_loc <mode> : set location telemetry mode all/selected/off
3343
+ telemetry_mode_env <mode> : set env telemetry mode all/selected/off
3344
+ advert_loc_policy <policy> : "share" means loc will be shared in adv
3345
+ manual_add_contacts <on/off>: let user manually add contacts to device
3346
+ - when off device automatically adds contacts from adverts
3347
+ - when on contacts must be added manually using add_pending
3348
+ (pending contacts list is built by meshcli from adverts while connected)
3349
+ display:
3350
+ print_snr <on/off> : toggle snr display in messages
3351
+ print_adverts <on/off> : display adverts as they come
3352
+ print_new_contacts <on/off> : display new pending contacts when available
3353
+ print_path_updates <on/off> : display path updates as they come
3354
+ json_log_rx <on/off> : logs packets incoming to device as json
3355
+ channel_echoes <on/off> : print repeats for channel data
3356
+ echo_unk_channels <on/off> : also dump unk channels (encrypted)
3357
+ color <on/off> : color off should remove ANSI codes from output
3358
+ meshcore-cli behaviour:
3359
+ classic_prompt <on/off> : activates less fancier prompt
3360
+ arrow_head <string> : change arrow head in prompt
3361
+ slash_start <string> : idem for slash start
3362
+ slash_end <string> : slash end
3363
+ invert_slash <on/off> : apply color inversion to slash
3364
+ auto_update_contacts <on/of>: auto sync contact list with device
3365
+ """)
3366
+
3367
+ elif cmdname == "scope":
3368
+ print("""scope <scope> : changes flood scope of the node
3369
+
3370
+ The scope command can be used from command line or interactive mode to set the region in which flood packets will be transmitted.
3371
+
3372
+ Managing Flood Scope in interactive mode
3373
+ Flood scope has recently been introduced in meshcore (from v1.10.0). It limits the scope of packets to regions, using transport codes in the frame.
3374
+ When entering chat mode, scope will be reset to *, meaning classic flood.
3375
+ You can switch scope using the scope command, or postfixing the to command with %<scope>.
3376
+ Scope can also be applied to a command using % before the scope name. For instance login%#Morbihan will limit diffusion of the login command (which is usually sent flood to get the path to a repeater) to the #Morbihan region.
3377
+ """)
3378
+
3379
+ elif cmdname == "contact_info":
3380
+ print("""contact_info <ct> : displays contact info
3381
+
3382
+ in interactive mode, there are some lighter commands that can be chained to give more compact information
3383
+ - contact_name (cn)
3384
+ - contact_key (ck)
3385
+ - contact_type (ct)
3386
+ """)
3387
+
3388
+ elif cmdname == "pending_contacts" or cmdname == "flush_pending" or cmdname == "add_pending":
3389
+ print("""Contact management
3390
+
3391
+ To receive a message from another user, it is necessary to have its public key. This key is stored on a contact list in the device, and this list has a finite size (50 when meshcore started, now over 350 for most devices).
3392
+
3393
+ By default contacts are automatically added to the device contact list when an advertisement is received, so as soon as you receive an advert, you can talk with your buddy.
3394
+
3395
+ With growing number of users, it becomes necessary to manage contact list and one of the ways is to add contacts manually to the device. This is done by turning on manual_add_contacts. Once this option has been turned on, a pending list is built by meshcore-cli from the received adverts. You can view the list issuing a pending_contacts command, flush the list using flush_pending or add a contact from the list with add_pending followed by the key of the contact or its name (both will be auto-completed with tab).
3396
+
3397
+ This feature only really works in interactive mode.
3398
+
3399
+ Note: There is also an auto_update_contacts setting that has nothing to do with adding contacts, it permits to automatically sync contact lists between device and meshcore-cli (when there is an update in name, location or path).
3400
+ """)
3401
+
3224
3402
  else:
3225
3403
  print(f"Sorry, no help yet for {cmdname}")
3226
3404
 
@@ -3236,18 +3414,26 @@ async def main(argv):
3236
3414
  baudrate = 115200
3237
3415
  timeout = 2
3238
3416
  pin = None
3417
+ first_device = False
3239
3418
  # If there is an address in config file, use it by default
3240
3419
  # unless an arg is explicitely given
3241
3420
  if os.path.exists(MCCLI_ADDRESS) :
3242
3421
  with open(MCCLI_ADDRESS, encoding="utf-8") as f :
3243
3422
  address = f.readline().strip()
3244
3423
 
3245
- opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:Pc:")
3424
+ try:
3425
+ opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:Pc:C")
3426
+ except getopt.GetoptError:
3427
+ print("Unrecognized option, use -h to get more help")
3428
+ command_usage()
3429
+ return
3246
3430
  for opt, arg in opts :
3247
3431
  match opt:
3248
3432
  case "-c" :
3249
3433
  if arg == "off":
3250
3434
  process_event_message.color = False
3435
+ case "-C":
3436
+ interactive_loop.classic = not interactive_loop.classic
3251
3437
  case "-d" : # name specified on cmdline
3252
3438
  address = arg
3253
3439
  case "-a" : # address specified on cmdline
@@ -3277,6 +3463,7 @@ async def main(argv):
3277
3463
  return
3278
3464
  case "-f": # connect to first encountered device
3279
3465
  address = ""
3466
+ first_device = True
3280
3467
  case "-l" :
3281
3468
  print("BLE devices:")
3282
3469
  try :
@@ -3372,8 +3559,15 @@ async def main(argv):
3372
3559
 
3373
3560
  try :
3374
3561
  mc = await MeshCore.create_ble(address=address, device=device, client=client, debug=debug, only_error=json_output, pin=pin)
3562
+ except BleakError :
3563
+ print("BLE connection asked (default behaviour), but no BLE HW found")
3564
+ print("Call meshcore-cli with -h for some more help (on commands)")
3565
+ command_usage()
3566
+ return
3375
3567
  except ConnectionError :
3376
3568
  logger.info("Error while connecting, retrying once ...")
3569
+ if first_device :
3570
+ address = "" # reset address to change device if first_device was asked
3377
3571
  if device is None and client is None: # Search for device
3378
3572
  logger.info(f"Scanning BLE for device matching {address}")
3379
3573
  devices = await BleakScanner.discover(timeout=timeout)