meshcore_cli-1.5.7/0000775000175000017500000000000015175460055013536 5ustar winniewinniemeshcore_cli-1.5.7/src/0000775000175000017500000000000015175460055014325 5ustar winniewinniemeshcore_cli-1.5.7/src/meshcore_cli/0000775000175000017500000000000015175460055016761 5ustar winniewinniemeshcore_cli-1.5.7/src/meshcore_cli/__main__.py0000644000175000017500000000012313615410400021027 0ustar winniewinnieif __name__ == "__main__": from meshcore_cli.meshcore_cli import cli cli() meshcore_cli-1.5.7/src/meshcore_cli/__init__.py0000644000175000017500000000000013615410400021040 0ustar winniewinniemeshcore_cli-1.5.7/src/meshcore_cli/meshcore_cli.py0000644000175000017500000060661613615410400021766 0ustar winniewinnie#!/usr/bin/python """ mccli.py : CLI interface to MeschCore BLE companion app """ import asyncio import os, sys, io, platform import time, datetime import getopt, json, shlex, re import logging import requests import serial.tools.list_ports from pathlib import Path import traceback from prompt_toolkit.shortcuts import PromptSession from prompt_toolkit.shortcuts import CompleteStyle from prompt_toolkit.completion import NestedCompleter, PathCompleter from prompt_toolkit.completion import CompleteEvent, Completer, Completion from prompt_toolkit.history import FileHistory from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.shortcuts import radiolist_dialog from prompt_toolkit.completion.word_completer import WordCompleter from prompt_toolkit.document import Document try: from bleak import BleakScanner, BleakClient from bleak.exc import BleakError, BleakDBusError BLEAK_AVAILABLE = True except ImportError: BLEAK_AVAILABLE = False import re from meshcore import MeshCore, EventType, logger # Version VERSION = "v1.5.7" # default ble address is stored in a config file MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/" MCCLI_ADDRESS = MCCLI_CONFIG_DIR + "default_address" MCCLI_HISTORY_FILE = MCCLI_CONFIG_DIR + "history" MCCLI_INIT_SCRIPT = MCCLI_CONFIG_DIR + "init" PAYLOAD_TYPENAMES = ["REQ", "RESPONSE", "TEXT_MSG", "ACK", "ADVERT", "GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH", "TRACE", "MULTIPART", "CONTROL"] ROUTE_TYPENAMES = ["TC_FLOOD", "FLOOD", "DIRECT", "TC_DIRECT"] CONTACT_TYPENAMES = ["NONE","CLI","REP","ROOM","SENS"] # Fallback address if config file not found # if None or "" then a scan is performed ADDRESS = "" JSON = False PS = None CS = None #ย Ansi colors ANSI_END = "\033[0m" ANSI_INVERT = "\033[7m" ANSI_NORMAL = "\033[27m" ANSI_GREEN = "\033[0;32m" ANSI_BGREEN = "\033[1;32m" ANSI_DGREEN="\033[0;38;5;22m" ANSI_BLUE = "\033[0;34m" ANSI_BBLUE = "\033[1;34m" ANSI_RED = "\033[0;31m" ANSI_BRED = "\033[1;31m" ANSI_MAGENTA = "\033[0;35m" ANSI_BMAGENTA = "\033[1;35m" ANSI_CYAN = "\033[0;36m" ANSI_BCYAN = "\033[1;36m" ANSI_LIGHT_BLUE = "\033[0;94m" ANSI_LIGHT_GREEN = "\033[0;92m" ANSI_LIGHT_YELLOW = "\033[0;93m" ANSI_LIGHT_GRAY="\033[0;38;5;247m" ANSI_BGRAY="\033[1;38;5;247m" ANSI_GRAY_BACK="\033[48;5;247m" ANSI_RESET_BACK="\033[49m" ANSI_ORANGE="\033[0;38;5;214m" ANSI_BORANGE="\033[1;38;5;214m" #ANSI_YELLOW="\033[0;38;5;226m" #ANSI_BYELLOW="\033[1;38;5;226m" ANSI_YELLOW = "\033[0;33m" ANSI_BYELLOW = "\033[1;33m" ANSI_START = "\033[" #Unicode chars # some possible symbols for prompts ๐Ÿญฌ๐Ÿฌ›๐Ÿฌ—๐Ÿญฌ๐Ÿฌ›๐Ÿฌƒ๐Ÿฌ—๐Ÿญฌ๐Ÿฌ›๐Ÿฌƒ๐Ÿฌ—๐Ÿฌ๐Ÿญ€๐Ÿญ‹๐Ÿญจ๐Ÿฎ‹๎‚ผ๎‚บ ARROW_HEAD = "๎‚ฐ" SLASH_END = f"๎‚บ{ANSI_RESET_BACK}" #SLASH_START = "๎‚ผ" SLASH_START = f"{ANSI_GRAY_BACK}" INVERT_SLASH = False def escape_ansi(line): ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]') return ansi_escape.sub('', line) def print_one_line_above(str): """ prints a string above current line """ width = os.get_terminal_size().columns stringlen = len(escape_ansi(str))-1 lines = divmod(stringlen, width)[0] + 1 print("\u001B[s", end="") # Save current cursor position print("\u001B[A", end="") # Move cursor up one line print("\u001B[999D", end="") # Move cursor to beginning of line for _ in range(lines): print("\u001B[S", end="") # Scroll up/pan window down 1 line print("\u001B[L", end="") # Insert new line for _ in range(lines - 1): print("\u001B[A", end="") # Move cursor up one line print(str, end="") # Print output status msg print("\u001B[u", end="", flush=True) # Jump back to saved cursor position def print_above(str): lines = str.split('\n') for l in lines: print_one_line_above(l) async def process_event_message(mc, ev, json_output, end="\n", above=False): """ display incoming message """ if ev is None : logger.error("Event does not contain message.") elif ev.type == EventType.NO_MORE_MSGS: logger.debug("No more messages") return False elif ev.type == EventType.ERROR: logger.error(f"Error retrieving messages: {ev.payload}") return False elif json_output : if above : print_above(json.dumps(ev.payload)) else: print(json.dumps(ev.payload), end=end, flush=True) else : await mc.ensure_contacts() data = ev.payload path_str = "" if process_event_message.timestamp != "" and process_event_message.timestamp != "off": ts = data["sender_timestamp"] if process_event_message.timestamp == "on": if (abs(time.time()-ts) < 86400): fmt = "%H:%M" else: fmt = "%y-%m-%d %H:%M" else: fmt = process_event_message.timestamp path_str += f'{datetime.datetime.fromtimestamp(ts).strftime(fmt)},' if data['path_len'] == 255 : path_str += "D" else : path_str += f"{data['path_len']}" if "SNR" in data and process_event_message.print_snr: path_str = path_str + f",{data['SNR']}dB" if (data['type'] == "PRIV") : ct = mc.get_contact_by_key_prefix(data['pubkey_prefix']) if ct is None: logger.debug(f"Unknown contact with pubkey prefix: {data['pubkey_prefix']}") name = data["pubkey_prefix"] else: name = ct["adv_name"] process_event_message.last_node=ct if ct is None: # Unknown disp = f"{ANSI_RED}" elif ct["type"] == 4 : # sensor disp = f"{ANSI_YELLOW}" elif ct["type"] == 3 : # room disp = f"{ANSI_CYAN}" elif ct["type"] == 2 : # repeater disp = f"{ANSI_MAGENTA}" else: disp = f"{ANSI_BLUE}" disp = disp + f"{name}" if 'signature' in data: sender = mc.get_contact_by_key_prefix(data['signature']) if sender is None: disp = disp + f"/{ANSI_RED}{data['signature']}" else: disp = disp + f"/{ANSI_BLUE}{sender['adv_name']}" disp = disp + f" {ANSI_ORANGE}({path_str})" if data["txt_type"] == 1: disp = disp + f"{ANSI_LIGHT_GRAY}" else: disp = disp + f"{ANSI_END}" disp = disp + f": {data['text']}" if not process_event_message.color: disp = escape_ansi(disp) if above: print_above(disp) else: print(disp, flush=True) elif (data['type'] == "CHAN") : path_str = f"{ANSI_YELLOW}({path_str}){ANSI_END}" if hasattr(mc, "channels"): ch_name = mc.channels[data['channel_idx']]['channel_name'] disp = f"{ANSI_GREEN}{ch_name} {path_str}" process_event_message.last_node = {"adv_name" : ch_name, "type" : 0, "chan_nb" : data['channel_idx']} elif data["channel_idx"] == 0: #public disp = f"{ANSI_GREEN}public {path_str}" process_event_message.last_node = {"adv_name" : "public", "type" : 0, "chan_nb" : 0} else : disp = f"{ANSI_GREEN}ch{data['channel_idx']} {path_str}" process_event_message.last_node = {"adv_name" : f"ch{data['channel_idx']}", "type" : 0, "chan_nb" : data['channel_idx']} disp = disp + f"{ANSI_END}" disp = disp + f": {data['text']}" if not process_event_message.color: disp = escape_ansi(disp) if above: print_above(disp) else: print(disp) else: print(json.dumps(ev.payload)) return True process_event_message.print_snr=False process_event_message.color=True process_event_message.last_node=None process_event_message.timestamp="" async def handle_log_rx(event): mc = handle_log_rx.mc payload_type = event.payload["payload_type"] if payload_type == 0x05: # flood msg / channel if handle_log_rx.channel_echoes: if "chan_name" in event.payload: chan_name = event.payload["chan_name"] else: chan_name = "" if "message" in event.payload : message = event.payload["message"] elif handle_log_rx.echo_unk_chans or chan_name != "": if chan_name == "": chan_name = event.payload["chan_hash"] if "crypted" in event.payload: message = event.payload["crypted"] if chan_name != "" : width = os.get_terminal_size().columns cars = width - 13 - len(event.payload["path"]) - len(chan_name) - 1 dispmsg = message.replace("\n","")[0:cars] txt = f"{ANSI_LIGHT_GRAY}{chan_name} {ANSI_DGREEN}{dispmsg+(cars-len(dispmsg))*' '} {ANSI_START}{width-11-len(event.payload['path'])}G{ANSI_YELLOW}[{event.payload['path']}]{ANSI_LIGHT_GRAY}{event.payload['snr']:6,.2f}{event.payload['rssi']:4}{ANSI_END}" if handle_message.above: print_above(txt) else: print(txt) elif payload_type == 0x04: # Advert if handle_log_rx.advert_echoes: adv_key = event.payload["adv_key"] adv_timestamp = event.payload["adv_timestamp"] signature = event.payload["signature"] flags = event.payload["adv_flags"] adv_type = event.payload["adv_type"] adv_lat = event.payload["adv_lat"] if "adv_lat" in event.payload else None adv_lon = event.payload["adv_lon"] if "adv_lon" in event.payload else None adv_feat1 = event.payload["adv_feat1"] if "adv_feat1" in event.payload else None adv_feat2 = event.payload["adv_feat2"] if "adv_feat2" in event.payload else None if "adv_name" in event.payload: adv_name = event.payload["adv_name"] else: # try to get the name from the contact ct = handle_log_rx.mc.get_contact_by_key_prefix(adv_key) if ct is None: adv_name = adv_key[0:12] else: adv_name = ct["adv_name"] ts_str = "" if process_event_message.timestamp != "" and process_event_message.timestamp != "off": ts = adv_timestamp if process_event_message.timestamp == "on": if (abs(time.time()-ts) < 86400): fmt = "%H:%M" else: fmt = "%y-%m-%d %H:%M" else: fmt = process_event_message.timestamp ts_str = f' at {datetime.datetime.fromtimestamp(ts).strftime(fmt)}' txt = f"{ANSI_LIGHT_GRAY}Advert for{ANSI_END} {adv_name}{ANSI_GREEN}/{CONTACT_TYPENAMES[adv_type]}{ts_str}{ANSI_END}" if not adv_lat is None: txt += f" {ANSI_LIGHT_GRAY}coords: {adv_lat},{adv_lon}" txt += f" {ANSI_YELLOW}path: [{event.payload['path']}] {ANSI_LIGHT_GRAY}snr: {event.payload['snr']:.2f}dB{ANSI_END}" if handle_message.above: print_above(txt) else: print(txt) event.payload["pkt_payload"] = event.payload["pkt_payload"].hex() # convert for json serialization if handle_log_rx.json_log_rx: # json mode ... raw dump msg = json.dumps(event.payload) if handle_message.above: print_above(msg) else : print(msg) handle_log_rx.json_log_rx = False handle_log_rx.channel_echoes = False handle_log_rx.advert_echoes = False handle_log_rx.mc = None handle_log_rx.echo_unk_chans=False async def handle_advert(event): if not handle_advert.print_adverts: return if handle_message.json_output: msg = json.dumps({"event": "advert", "public_key" : event.payload["public_key"]}) else: key = event.payload["public_key"] contact = handle_advert.mc.get_contact_by_key_prefix(key) name = "" if not contact is None : name = contact["adv_name"] msg = f"Got advert from {name} [{key}]" if handle_message.above: print_above(msg) else : print(msg) handle_advert.print_adverts=False handle_advert.mc=None async def handle_path_update(event): if not handle_path_update.print_path_updates: return if handle_message.json_output: msg = json.dumps({"event": "path_update", "public_key" : event.payload["public_key"]}) else: key = event.payload["public_key"] contact = handle_path_update.mc.get_contact_by_key_prefix(key) name = "" if not contact is None : name = contact["adv_name"] msg = f"Got path update for {name} [{key}]" if handle_message.above: print_above(msg) else : print(msg) handle_path_update.print_path_updates=False handle_path_update.mc=None async def handle_new_contact(event): if not handle_new_contact.print_new_contacts: return if handle_message.json_output: msg = json.dumps({"event": "new_contact", "contact" : event.payload}) else: key = event.payload["public_key"] name = event.payload["adv_name"] msg = f"New pending contact {name} [{key}]" if handle_message.above: print_above(msg) else : print(msg) handle_new_contact.print_new_contacts=False async def log_message(mc, msg): if log_message.file is None: return if msg["type"] == "PRIV" : ct = mc.get_contact_by_key_prefix(msg['pubkey_prefix']) if ct is None: msg["name"] = msg["pubkey_prefix"] msg["sender"] = msg["pubkey_prefix"] else: msg["name"] = ct["adv_name"] msg["sender"] = ct["adv_name"] elif msg["type"] == "CHAN" : if hasattr(mc, 'channels') : msg["sender"] = mc.channels[msg['channel_idx']]["channel_name"] else: msg["sender"] = f"channel {msg['channel_idx']}" msg["name"] = msg["sender"] msg["timestamp"] = int(time.time()) with open(log_message.file, "a") as logfile: logfile.write(json.dumps(msg) + "\n") log_message.file=None async def handle_message(event): """ Process incoming message events """ if handle_message.display : await process_event_message(handle_message.mc, event, above=handle_message.above, json_output=handle_message.json_output) await log_message(handle_message.mc, event.payload.copy()) handle_message.json_output=False handle_message.mc=None handle_message.above=False handle_message.display=True async def subscribe_to_msgs(mc, json_output=False, above=False): """ Subscribe to incoming messages """ global PS, CS await mc.ensure_contacts() handle_message.json_output = json_output handle_message.above = above # Subscribe to private messages if PS is None : PS = mc.subscribe(EventType.CONTACT_MSG_RECV, handle_message) # Subscribe to channel messages if CS is None : CS = mc.subscribe(EventType.CHANNEL_MSG_RECV, handle_message) await mc.start_auto_message_fetching() # redefine get_completion to let user put symbols in first item # and handle navigating in path ... class MyNestedCompleter(NestedCompleter): def get_completions( self, document, complete_event): txt = document.text_before_cursor.lstrip() if not " " in txt: if txt != "" and txt[0] == "/" and txt.count("/") == 1: opts = [] for k in self.options.keys(): if k[0] == "/" : v = "/" + k.split("/")[1] #+ ("/" if k.count("/") == 2 else "") if v not in opts: opts.append(v) else: opts = self.options.keys() completer = WordCompleter( opts, ignore_case=self.ignore_case, pattern=re.compile(r"([a-zA-Z0-9_\\/\#\?]+|[^a-zA-Z0-9_\s\#\?]+)")) yield from completer.get_completions(document, complete_event) else: # normal behavior for remainder yield from super().get_completions(document, complete_event) def make_completion_dict(contacts, pending={}, to=None, channels=None): contact_list = {} pending_list = {} to_list = {} to_list["~"] = None to_list["/"] = None if not process_event_message.last_node is None: to_list["!"] = None to_list[".."] = None it = iter(contacts.items()) for c in it : contact_list[c[1]['adv_name']] = None pit = iter(pending.items()) for c in pit : pending_list[c[1]['adv_name']] = None pit = iter(pending.items()) for c in pit : pending_list[c[1]['public_key']] = None to_list.update(contact_list) to_list["ch"] = None to_list["ch0"] = None if not channels is None: for c in channels : if c["channel_name"] != "": to_list[c["channel_name"]] = None completion_list = { "to" : to_list, "/to" : to_list, "public" : None, "chan" : None, } root_completion_list = { "ver" : None, "infos" : None, "advert" : None, "floodadv" : None, "msg" : contact_list, "wait_ack" : None, "time" : None, "clock" : {"sync" : None}, "reboot" : None, "card" : None, "upload_card" : None, "contacts": None, "pending_contacts": None, "add_pending": pending_list, "flush_pending": None, "contact_info": contact_list, "export_contact" : contact_list, "upload_contact" : contact_list, "share_contact" : contact_list, "path": contact_list, "disc_path" : contact_list, "advert_path" : contact_list | pending_list, "node_discover": {"all":None, "sens":None, "rep":None, "comp":None, "room":None, "cli":None}, "trace" : None, "reset_path" : contact_list, "change_path" : contact_list, "change_flags" : contact_list, "remove_contact" : contact_list, "import_contact" : {"meshcore://":None}, "reload_contacts" : None, "login" : contact_list, "cmd" : contact_list, "req_status" : contact_list, "req_neighbours": contact_list, "logout" : contact_list, "req_telemetry" : contact_list, "req_binary" : contact_list, "req_mma" : contact_list, "req_owner" : contact_list, "req_regions" : contact_list, "req_clock" : contact_list, "self_telemetry" : None, "get_channel": None, "set_channel": None, "add_channel": None, "get_channels": None, "remove_channel": None, "apply_to": None, "at": None, "scope": None, "set" : { "name" : None, "pin" : None, "radio" : {",,,":None, "f,bw,sf,cr":None}, "tx" : None, "tuning" : {",", "af,tx_d"}, "lat" : None, "lon" : None, "coords" : None, "private_key": None, "print_snr" : {"on":None, "off": None}, "print_timestamp" : {"on":None, "off": None, "%Y:%M":None}, "json_msgs" : {"on":None, "off": None}, "color" : {"on":None, "off":None}, "print_adverts" : {"on":None, "off":None}, "json_log_rx" : {"on":None, "off":None}, "channel_echoes" : {"on":None, "off":None}, "advert_echoes" : {"on":None, "off":None}, "echo_unk_chans" : {"on":None, "off":None}, "print_new_contacts" : {"on": None, "off":None}, "print_path_updates" : {"on":None,"off":None}, "classic_prompt" : {"on" : None, "off":None}, "manual_add_contacts" : {"on" : None, "off":None}, "autoadd_config" : None, "telemetry_mode_base" : {"always" : None, "device":None, "never":None}, "telemetry_mode_loc" : {"always" : None, "device":None, "never":None}, "telemetry_mode_env" : {"always" : None, "device":None, "never":None}, "advert_loc_policy" : {"none" : None, "share" : None}, "auto_update_contacts" : {"on":None, "off":None}, "multi_acks" : {"on": None, "off":None}, "max_attempts" : None, "max_flood_attempts" : None, "flood_after" : None, "path_hash_mode": None, "default_scope": None, }, "get" : {"name":None, "bat":None, "fstats": None, "radio":None, "tx":None, "coords":None, "lat":None, "lon":None, "private_key":None, "print_snr":None, "print_timestamp":None, "json_msgs":None, "color":None, "print_adverts":None, "json_log_rx":None, "channel_echoes":None, "advert_echoes":None, "echo_unk_chans":None, "print_path_updates":None, "print_new_contacts":None, "classic_prompt":None, "manual_add_contacts":None, "autoadd_config":None, "telemetry_mode_base":None, "telemetry_mode_loc":None, "telemetry_mode_env":None, "advert_loc_policy":None, "auto_update_contacts":None, "multi_acks":None, "max_attempts":None, "max_flood_attempts":None, "flood_after":None, "custom":None, "stats":None, "status":None, "stats_core":None, "stats_radio":None, "stats_packets":None, "allowed_repeat_freq":None, "path_hash_mode":None, "default_scope":None, }, "?get":None, "?set":None, "?scope":None, "?contact_info":None, "?apply_to":None, "?at":None, "?node_discover":None, "?nd":None, "?pending_contacts":None, "?add_pending":None, "?flush_pending":None, "?get_channels":None, "?set_channel":None, "?get_channel":None, "?set_channel":None, "?add_channel":None, "?remove_channel":None, "?path":None, "?change_path":None, "?trace":None, } contact_completion_list = { "contact_info": None, "contact_name": None, "contact_key": None, "contact_type": None, "contact_lastmod": None, "export_contact" : None, "share_contact" : None, "upload_contact" : None, "path": None, "disc_path": None, "advert_path": None, "trace": None, "dtrace": None, "reset_path" : None, "change_path" : None, "change_flags" : None, "req_telemetry" : None, "req_binary" : None, "forget_password" : None, } client_completion_list = dict(contact_completion_list) client_completion_list.update({ "get" : { "timeout":None, }, "set" : { "timeout":None, }, }) repeater_completion_list = dict(contact_completion_list) repeater_completion_list.update({ "login" : None, "logout" : None, "req_status" : None, "req_neighbours": None, "cmd" : None, "ver" : None, "advert" : None, "time" : None, "clock" : {"sync" : None}, "reboot" : None, "clkreboot":None, "start ota" : None, "password" : None, "neighbors" : None, "neighbor.remove": contact_list, "discover.neighbors": None, "req_acl":None, "req_owner" :None, "req_regions" :None, "req_clock" :None, "setperm":contact_list, "region" : {"get":None, "allowf": None, "denyf": None, "put": None, "remove": None, "save": None, "home": None}, "gps" : {"on":None,"off":None,"sync":None,"setloc":None, "advert" : {"none": None, "share": None, "prefs": None}, }, "powersaving" : {"on":None,"off":None}, "sensor": {"list": None, "set": {"gps": None}, "get": {"gps": None}}, "get" : {"name" : None, "role":None, "radio" : None, "freq":None, "tx":None, "af" : None, "repeat" : None, "allow.read.only" : None, "flood.advert.interval" : None, "flood.max":None, "advert.interval" : None, "guest.password" : None, "owner.info":None, "rxdelay": None, "txdelay": None, "direct.tx_delay":None, "public.key":None, "lat" : None, "lon" : None, "telemetry" : None, "status" : None, "timeout" : None, "acl":None, "bridge.enabled":None, "bridge.delay":None, "bridge.source":None, "bridge.baud":None, "bridge.secret":None, "bridge.type":None, }, "set" : {"name" : None, "radio" : {",,,":None, "f,bw,sf,cr": None}, "freq" : None, "tx" : None, "af": None, "repeat" : {"on": None, "off": None}, "flood.advert.interval" : None, "flood.max" : None, "advert.interval" : None, "guest.password" : None, "owner.info":None, "allow.read.only" : {"on": None, "off": None}, "rxdelay" : None, "txdelay": None, "direct.txdelay" : None, "lat" : None, "lon" : None, "timeout" : None, "perm":contact_list, "bridge.enabled":{"on": None, "off": None}, "bridge.delay":None, "bridge.source":None, "bridge.baud":None, "bridge.secret":None, }, "erase": None, "log" : {"start" : None, "stop" : None, "erase" : None} }) sensor_completion_list = dict(repeater_completion_list) sensor_completion_list.update({"req_mma":{"begin end":None}}) sensor_completion_list["get"].update({ "mma":None, }) if to is None : completion_list.update(dict(root_completion_list)) completion_list["set"].update(make_completion_dict.custom_vars) completion_list["get"].update(make_completion_dict.custom_vars) else : completion_list.update({ "send" : None, }) if to['type'] == 1 : completion_list.update(client_completion_list) if to['type'] == 2 or to['type'] == 3 : # repeaters and room servers completion_list.update(repeater_completion_list) if (to['type'] == 4) : #specific to sensors completion_list.update(sensor_completion_list) slash_root_completion_list = {} for k,v in root_completion_list.items(): slash_root_completion_list["/"+k]=v completion_list.update(slash_root_completion_list) slash_contacts_completion_list = {} for k,v in contacts.items(): d={} if v["type"] == 1: l = client_completion_list elif v["type"] == 2 or v["type"] == 3: l = repeater_completion_list elif v["type"] == 4: l = sensor_completion_list for kk, vv in l.items(): d["/" + v["adv_name"] + "/" + kk] = vv slash_contacts_completion_list.update(d) completion_list.update(slash_contacts_completion_list) slash_chan_completion_list = {} if not channels is None: for c in channels : if c["channel_name"] != "": slash_chan_completion_list["/" + c["channel_name"]] = None completion_list.update(slash_chan_completion_list) completion_list.update({ "script" : None, "quit" : None }) return completion_list make_completion_dict.custom_vars = {} async def interactive_loop(mc, to=None) : print("""Interactive mode, most commands from terminal chat should work. Use \"to\" to select recipient, use Tab to complete name ... Some cmds have an help accessible with ?. Do ?[Tab] to get a list. \"quit\", \"q\", CTRL+D will end interactive mode""") contact = to prev_contact = None scope = await set_scope(mc, "*") prev_scope = scope await get_contacts(mc, anim=True) await get_channels(mc, anim=True) #ย Call sync_msg before going further so there is no issue when scrolling # long list of msgs await next_cmd(mc, ["sync_msgs"]) await subscribe_to_msgs(mc, above=True) try: if os.path.isdir(MCCLI_CONFIG_DIR) : our_history = FileHistory(MCCLI_HISTORY_FILE) else: our_history = None # beware, mouse support breaks mouse scroll ... session = PromptSession(history=our_history, wrap_lines=False, mouse_support=False, complete_style=CompleteStyle.MULTI_COLUMN) bindings = KeyBindings() res = await mc.commands.get_custom_vars() cv = [] if res.type != EventType.ERROR : cv = list(res.payload.keys()) make_completion_dict.custom_vars = {k:None for k in cv} # Add our own key binding. @bindings.add("escape") def _(event): event.app.current_buffer.cancel_completion() last_ack = True while True: # reset scope (if changed) scope = await set_scope(mc, scope) color = process_event_message.color classic = interactive_loop.classic or not color if classic: prompt = "" else: prompt = f"{ANSI_INVERT}" prompt = prompt + f"{ANSI_BGRAY}" prompt = prompt + f"{mc.self_info['name']}" if contact is None: # display scope if not scope is None: prompt = prompt + f"|{scope}" if contact is None : if classic : prompt = prompt + ">" else : prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}" else: if classic : prompt = prompt + "/" else : if INVERT_SLASH: prompt = prompt + f"{ANSI_INVERT}" else: prompt = prompt + f"{ANSI_NORMAL}" prompt = prompt + f"{SLASH_START}" if not last_ack: prompt = prompt + f"{ANSI_BRED}" if classic : prompt = prompt + "!" elif contact["type"] == 4 : # sensor prompt = prompt + f"{ANSI_BYELLOW}" elif contact["type"] == 3 : # room server prompt = prompt + f"{ANSI_BCYAN}" elif contact["type"] == 2 : prompt = prompt + f"{ANSI_BMAGENTA}" elif contact["type"] == 0 : # public channel prompt = prompt + f"{ANSI_BGREEN}" else : prompt = prompt + f"{ANSI_BBLUE}" if not classic: prompt = prompt + f"{SLASH_END}" prompt = prompt + f"{ANSI_INVERT}" prompt = prompt + f"{contact['adv_name']}" if contact["type"] == 0 or contact["out_path_len"]==-1: if scope is None: prompt = prompt + f"|*" else: prompt = prompt + f"|{scope}" else: # display path to dest or 0 if 0 hop if contact["out_path_len"] == 0: prompt = prompt + f"|0" else: path = contact['out_path'] plen = contact['out_path_len'] phs = contact['out_path_hash_mode'] + 1 path_str = path[:2] for i in range(1,plen): path_str = path_str + path[i*phs*2:i*2*phs+2] prompt = prompt + "|" + path_str if classic : prompt = prompt + f"{ANSI_NORMAL}>" else: prompt = prompt + f"{ANSI_NORMAL}{ARROW_HEAD}" prompt = prompt + f"{ANSI_END}" prompt = prompt + " " if not color : prompt=escape_ansi(prompt) session.app.ttimeoutlen = 0.2 session.app.timeoutlen = 0.2 completer = MyNestedCompleter.from_nested_dict( make_completion_dict(mc.contacts, mc.pending_contacts, to=contact, channels = mc.channels)) line = await session.prompt_async(ANSI(prompt), complete_while_typing=False, completer=completer, key_bindings=bindings) line = line.strip() if line == "" : # blank line pass elif line.startswith("?") : get_help_for(line[1:], context="chat") # raw meshcli command as on command line elif line.startswith("$") : try : args = shlex.split(line[1:]) await process_cmds(mc, args) except ValueError: logger.error("Error parsing line {line[1:]}") elif line.startswith("/scope") or\ line.startswith("scope") and contact is None: if not scope is None: prev_scope = scope try: newscope = line.split(" ", 1)[1] scope = await set_scope(mc, newscope) except IndexError: print(scope) elif line == "quit" or line == "q" or line == "/quit" or line == "/q" : break elif contact is None and (line.startswith("apply_to ") or line.startswith("at ")) or\ line.startswith("/apply_to ") or line.startswith("/at ") : try: await apply_command_to_contacts(mc, line.split(" ",2)[1], line.split(" ",2)[2]) except IndexError: logger.error(f"Error with apply_to command parameters") elif line.startswith("to ") or line.startswith("/to "): # dest dest = line.split(" ", 1)[1] if dest.startswith("\"") or dest.startswith("\'") : # if name starts with a quote dest = shlex.split(dest)[0] # use shlex.split to get contact name between quotes dest_scope = None if '%' in dest and scope!=None : dest_scope = dest.split("%")[-1] dest = dest[:-len(dest_scope)-1] nc = await get_contact_from_arg(mc, dest) if nc is None: if dest == "public" : nc = {"adv_name" : "public", "type" : 0, "chan_nb" : 0} if hasattr(mc, "channels"): nc["adv_name"] = mc.channels[0]["channel_name"] elif dest.startswith("ch"): dest = int(dest[2:]) nc = {"adv_name" : "chan" + str(dest), "type" : 0, "chan_nb" : dest} if hasattr(mc, "channels"): nc["adv_name"] = mc.channels[dest]["channel_name"] elif dest == ".." : # previous recipient nc = prev_contact if dest_scope is None and not prev_scope is None: dest_scope = prev_scope elif dest == "~" or dest == "/" or dest == mc.self_info['name']: nc = None elif dest == "!" : nc = process_event_message.last_node else : chan = await get_channel_by_name(mc, dest) if chan is None : print(f"Contact '{dest}' not found in contacts.") nc = contact else: nc = {"adv_name": chan["channel_name"], "type": 0, "chan_nb": chan["channel_idx"],} if nc != contact : last_ack = True prev_contact = contact contact = nc if dest_scope is None: dest_scope = scope if not scope is None and dest_scope != scope: prev_scope = scope if not dest_scope is None: scope = await set_scope(mc, dest_scope) elif line == "to" or line == "/to" : if contact is None : print(mc.self_info['name']) else: print(contact["adv_name"]) elif line.startswith("/") : path = line.split(" ", 1)[0] if path.count("/") == 1: args = line[1:].split(" ") dest = args[0] dest_scope = None if "%" in dest : dest_scope = dest.split("%")[-1] dest = dest[:-len(dest_scope)-1] await set_scope (mc, dest_scope) tct = await get_contact_from_arg(mc, dest) if len(args)>1 and not tct is None: # a contact, send a message if tct["type"] == 1 or tct["type"] == 3: # client or room last_ack = await msg_ack(mc, tct, line.split(" ", 1)[1]) else: print("Can only send msg to chan, client or room") else : ch = await get_channel_by_name(mc, dest) if len(args)>1 and not ch is None: # a channel, send message await send_chan_msg(mc, ch["channel_idx"], line.split(" ", 1)[1]) else : try : await process_cmds(mc, shlex.split(line[1:])) except ValueError: logger.error(f"Error processing line{line[1:]}") else: cmdline = line[1:].split("/",1)[1] contact_name = path[1:].split("/",1)[0] dest_scope = None if "%" in contact_name: dest_scope = contact_name.split("%")[-1] contact_name = contact_name[:-len(dest_scope)-1] await set_scope (mc, dest_scope) tct = await get_contact_from_arg(mc, contact_name) if tct is None: print(f"{contact_name} is not a contact") else: if not await process_contact_chat_line(mc, tct, cmdline): if cmdline != "": if tct["type"] == 1: last_ack = await msg_ack(mc, tct, cmdline) else : await process_cmds(mc, ["cmd", tct["adv_name"], cmdline]) # commands that take one parameter (don't need quotes) elif line.startswith("public ") : cmds = line.split(" ", 1) args = [cmds[0], cmds[1]] await process_cmds(mc, args) # lines starting with ! are sent as reply to last received msg elif line.startswith("!"): ln = process_event_message.last_node if ln is None : print("No received msg yet !") elif ln["type"] == 0 : await send_chan_msg(mc, ln["chan_nb"], line[1:]) else : last_ack = await msg_ack(mc, ln, line[1:]) if last_ack == False : contact = ln # commands are passed through if at root elif contact is None or line.startswith(".") : try: args = shlex.split(line) await process_cmds(mc, args) except ValueError: logger.error(f"Error processing {line}") elif await process_contact_chat_line(mc, contact, line): pass elif line == "list" : # list command from chat displays contacts on a line it = iter(mc.contacts.items()) first = True for c in it : if not first: print(", ", end="") first = False print(f"{c[1]['adv_name']}", end="") print("") elif line.startswith("send") or line.startswith("\"") : if line.startswith("send") : line = line[5:] if line.startswith("\"") : line = line[1:] last_ack = await msg_ack(mc, contact, line) elif contact["type"] == 0 : # channel, send msg to channel await send_chan_msg(mc, contact["chan_nb"], line) elif contact["type"] == 1 : # chat, send to recipient and wait ack last_ack = await msg_ack(mc, contact, line) elif contact["type"] == 2 or\ contact["type"] == 3 or\ contact["type"] == 4 : # repeater, room, sensor send cmd await process_cmds(mc, ["cmd", contact["adv_name"], line]) except (EOFError, KeyboardInterrupt): print("Exiting cli") except asyncio.CancelledError: # Handle task cancellation from KeyboardInterrupt in asyncio.run() print("Exiting cli") if platform.system() == "Darwin" or platform.system() == "Windows": interactive_loop.classic = True else: interactive_loop.classic = False async def process_contact_chat_line(mc, contact, line): if contact["type"] == 0: return False # if one element in line (most cases) strip the scope and apply it if not " " in line and "%" in line: dest_scope = line.split("%")[-1] line = line[:-len(dest_scope)-1] await set_scope (mc, dest_scope) if line.startswith(":") : # : will send a command to current recipient args=["cmd", contact['adv_name'], line[1:]] await process_cmds(mc, args) return True if line == "reset path" : # reset path for compat with terminal chat args = ["reset_path", contact['adv_name']] await process_cmds(mc, args) return True if line.startswith("contact_key") or line.startswith("ck"): print(contact['public_key'],end="") if " " in line: print(" ", end="", flush=True) secline = line.split(" ", 1)[1] await process_contact_chat_line(mc, contact, secline) else: print("") return True if line.startswith("contact_type") or line.startswith("ct"): print(f"{CONTACT_TYPENAMES[contact['type']]:4}",end="") if " " in line: print(" ", end="", flush=True) secline = line.split(" ", 1)[1] await process_contact_chat_line(mc, contact, secline) else: print("") return True if line.startswith("contact_name") or line.startswith("cn"): print(contact['adv_name'],end="") if " " in line: print(" ", end="", flush=True) secline = line.split(" ", 1)[1] await process_contact_chat_line(mc, contact, secline) else: print("") return True if line.startswith("contact_lastmod"): timestamp = contact["lastmod"] print(f"{contact['adv_name']} updated" f" {datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d at %H:%M:%S')}" f" ({timestamp})", end="") if " " in line: print(" ", end="", flush=True) secline = line.split(" ", 1)[1] await process_contact_chat_line(mc,contact, secline) else: print("") return True if line.startswith("path") : if contact['out_path_len'] == -1: print("Flood", end="") elif contact['out_path_len'] == 0: print("0 hop", end="") else: plen = contact['out_path_len'] phs = contact['out_path_hash_mode']+1 path_str_in = contact['out_path'] path_str = path_str_in[:2*phs] for i in range(1,plen): path_str = path_str + "," + path_str_in[i*phs*2:(i+1)*2*phs] print(f"{path_str}",end="") if " " in line: print(" ", end="", flush=True) secline = line.split(" ", 1)[1] await process_contact_chat_line(mc, contact, secline) else: print("") return True if line.startswith("sleep ") or line.startswith("s "): try: sleeptime = int(line.split(" ",2)[1]) cmd_pos = 2 except IndexError: # nothing arg after sleep sleeptime = 1 cmd_pos = 0 except ValueError: sleeptime = 1 cmd_pos = 1 try: if cmd_pos > 0: secline = line.split(" ",cmd_pos)[cmd_pos] await process_contact_chat_line(mc, contact, secline) except IndexError: pass # will sleep after executed command if there is a command await asyncio.sleep(sleeptime) return True # commands that take contact as second arg will be sent to recipient # and can be chained ... if line.startswith("sc") or line.startswith("share_contact") or\ line.startswith("ec") or line.startswith("export_contact") or\ line.startswith("uc") or line.startswith("upload_contact") or\ line.startswith("rp") or line.startswith("reset_path") or\ line.startswith("dp") or line.startswith("disc_path") or\ line.startswith("contact_info") or line.startswith("ci") or\ line.startswith("req_status") or line.startswith("rs") or\ line.startswith("req_neighbours") or line.startswith("rn") or\ line.startswith("req_telemetry") or line.startswith("rt") or\ line.startswith("req_regions") or line.startswith("rr") or\ line.startswith("req_owner") or line.startswith("ro") or\ line.startswith("req_clock") or\ line.startswith("req_acl") or line.startswith("ra") or\ line.startswith("path") or\ line.startswith("advert_path") or line.startswith("ap") or\ line.startswith("logout") : args = [line.split()[0], contact['adv_name']] await process_cmds(mc, args) if " " in line: secline = line.split(" ", 1)[1] await process_contact_chat_line(mc, contact, secline) return True # special case for rp that can be chained from cmdline if line.startswith("rp ") or line.startswith("reset_path ") : args = ["rp", contact['adv_name']] await process_cmds(mc, args) secline = line.split(" ", 1)[1] await process_contact_chat_line(mc, contact, secline) return True if line.startswith("set timeout "): cmds=line.split(" ") #args = ["contact_timeout", contact['adv_name'], cmds[2]] #await process_cmds(mc, args) contact["timeout"] = float(cmds[2]) return True if line == "get timeout": print(f"timeout: {0 if not 'timeout' in contact else contact['timeout']}") return True if contact["type"] == 4 and\ (line.startswith("get mma ")) or\ contact["type"] > 1 and\ (line.startswith("get telemetry") or line.startswith("get status") or line.startswith("get acl")): cmds = line.split(" ") args = [f"req_{cmds[1]}", contact['adv_name']] if len(cmds) > 2 : args = args + cmds[2:] if line.startswith("get mma ") and len(args) < 4: args.append("0") await process_cmds(mc, args) return True # special treatment for setperm to support contact name as param if contact["type"] > 1 and\ (line.startswith("setperm ") or line.startswith("set perm ")): try: cmds = shlex.split(line) off = 1 if line.startswith("set perm") else 0 name = cmds[1 + off] perm_string = cmds[2 + off] if (perm_string.startswith("0x")): perm = int(perm_string,0) elif (perm_string.startswith("#")): perm = int(perm_string[1:]) else: perm = int(perm_string,16) ct= await get_contact_from_arg(mc, name) if ct is None: if name == "self" or mc.self_info["public_key"].startswith(name): key = mc.self_info["public_key"] else: key = name else: key=ct["public_key"] newline=f"setperm {key} {perm}" await process_cmds(mc, ["cmd", contact["adv_name"], newline]) except IndexError: print("Wrong number of parameters") return True # trace called on a contact if line == "trace" or line == "tr" : await print_trace_to(mc, contact) return True if line == "dtrace" or line == "dt" : await print_disc_trace_to(mc, contact) return True # same but for commands with a parameter if " " in line: cmds = line.split(" ", 1) if "%" in cmds[0]: dest_scope = cmds[0].split("%")[-1] cmds[0] = cmds[0][:-len(dest_scope)-1] await set_scope(mc, dest_scope) if cmds[0] == "cmd" or cmds[0] == "msg" or\ cmds[0] == "cp" or cmds[0] == "change_path" or\ cmds[0] == "cf" or cmds[0] == "change_flags" or\ cmds[0] == "req_binary" or\ cmds[0] == "login" : args = [cmds[0], contact['adv_name'], cmds[1]] await process_cmds(mc, args) return True if line == "login": # use stored password or prompt for it password_file = "" password = "" if os.path.isdir(MCCLI_CONFIG_DIR) : # if a password file exists with node name open it and destroy it password_file = MCCLI_CONFIG_DIR + contact['adv_name'] + ".pass" if os.path.exists(password_file) : with open(password_file, "r", encoding="utf-8") as f : password=f.readline().strip() os.remove(password_file) password_file = MCCLI_CONFIG_DIR + contact["public_key"] + ".pass" with open(password_file, "w", encoding="utf-8") as f : f.write(password) # this is the new correct password file, using pubkey password_file = MCCLI_CONFIG_DIR + contact["public_key"] + ".pass" if os.path.exists(password_file) : with open(password_file, "r", encoding="utf-8") as f : password=f.readline().strip() if password == "": try: sess = PromptSession(f"Password for {contact['adv_name']}: ", is_password=True) password = await sess.prompt_async() except EOFError: logger.info("Canceled") return True if password_file != "": with open(password_file, "w", encoding="utf-8") as f : f.write(password) args = ["login", contact['adv_name'], password] await process_cmds(mc, args) return True if line.startswith("forget_password") or line.startswith("fp"): password_file = MCCLI_CONFIG_DIR + contact['adv_name'] + ".pass" if os.path.exists(password_file): os.remove(password_file) password_file = MCCLI_CONFIG_DIR + contact['public_key'] + ".pass" if os.path.exists(password_file): os.remove(password_file) try: secline = line.split(" ", 1)[1] await process_contact_chat_line(mc, contact, secline) except IndexError: pass return True if contact["type"] == 4 and \ (line.startswith("req_mma ") or line.startswith('rm ')) : cmds = line.split(" ") if len(cmds) < 3 : cmds.append("0") args = [cmds[0], contact['adv_name'], cmds[1], cmds[2]] await process_cmds(mc, args) return True return False async def apply_command_to_contacts(mc, contact_filter, line, json_output=False): upd_before = None upd_after = None contact_type = None min_hops = None max_hops = None flags = None count = 0 await mc.ensure_contacts() filters = contact_filter.split(",") for f in filters: if f == "all": pass elif f[0] == "u": #updated val_str = f[2:] t = time.time() if val_str[-1] == "d": # value in days t = t - float(val_str[0:-1]) * 86400 elif val_str[-1] == "h": # value in hours t = t - float(val_str[0:-1]) * 3600 elif val_str[-1] == "m": # value in minutes t = t - float(val_str[0:-1]) * 60 else: t = int(val_str) if f[1] == "<": #before upd_before = t elif f[1] == ">": upd_after = t else: logger.error(f"Time filter can only be < or >") return elif f[0] == "t": # type if f[1] == "=": contact_type = int(f[2:]) else: logger.error(f"Type can only be equals to a value") return elif f[0] == "d": # direct min_hops=0 elif f[0] == "f": # flood max_hops=-1 elif f[0] == "h": # hop number if f[1] == ">": min_hops = int(f[2:])+1 elif f[1] == "<": max_hops = int(f[2:])-1 elif f[1] == "=": min_hops = int(f[2:]) max_hops = int(f[2:]) elif f[0] == "b": # flag bits if f[1] == "=": flags = int(f[2:]) else: logger.error(f"Unknown filter {f}") return for c in dict(mc._contacts).items(): contact = c[1] if (contact_type is None or contact["type"] == contact_type) and\ (upd_before is None or contact["lastmod"] < upd_before) and\ (upd_after is None or contact["lastmod"] > upd_after) and\ (min_hops is None or contact["out_path_len"] >= min_hops) and\ (max_hops is None or contact["out_path_len"] <= max_hops) and\ (flags is None or contact["flags"] & flags == flags): count = count + 1 if await process_contact_chat_line(mc, contact, line): pass elif line == "remove_contact": args = [line, contact['adv_name']] await process_cmds(mc, args) elif line.startswith("send") or line.startswith("\"") : if line.startswith("send") : line = line[5:] if line.startswith("\"") : line = line[1:] await msg_ack(mc, contact, line) elif contact["type"] == 2 or\ contact["type"] == 3 or\ contact["type"] == 4 : # repeater, room, sensor send cmd await process_cmds(mc, ["cmd", contact["adv_name"], line]) # wait for a reply from cmd await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=7) else: logger.error(f"Can't send {line} to {contact['adv_name']}") if not json_output: print(f"> {count} matches in contacts") async def send_cmd (mc, contact, cmd) : res = await mc.commands.send_cmd(contact, cmd) if not res is None and not res.type == EventType.ERROR: res.payload["expected_ack"] = res.payload["expected_ack"].hex() if isinstance(contact, dict): sent = res.payload.copy() sent["type"] = "SENT_CMD" sent["recipient"] = contact["adv_name"] sent["text"] = cmd sent["txt_type"] = 1 sent["sender"] = mc.self_info['name'] sent["name"] = sent["recipient"] await log_message(mc, sent) return res async def send_chan_msg(mc, nb, msg): res = await mc.commands.send_chan_msg(nb, msg) if not res is None and not res.type == EventType.ERROR: sent = res.payload.copy() sent["type"] = "SENT_CHAN" sent["channel_idx"] = nb if hasattr(mc, "channels"): chan_name = mc.channels[nb]["channel_name"] else: chan_name = f"channel {nb}" sent["chan_name"] = chan_name sent["recipient"] = chan_name sent["text"] = msg sent["txt_type"] = 0 sent["sender"] = mc.self_info['name'] sent["name"] = chan_name await log_message(mc, sent) return res async def send_msg (mc, contact, msg) : res = await mc.commands.send_msg(contact, msg) if not res is None and not res.type == EventType.ERROR: res.payload["expected_ack"] = res.payload["expected_ack"].hex() if isinstance(contact, dict): sent = res.payload.copy() sent["type"] = "SENT_MSG" sent["recipient"] = contact["adv_name"] sent["text"] = msg sent["txt_type"] = 0 sent["sender"] = mc.self_info['name'] sent["name"] = sent["recipient"] await log_message(mc, sent) return res async def msg_ack (mc, contact, msg) : timeout = 0 if not isinstance(contact, dict) or not 'timeout' in contact\ else contact['timeout'] res = await mc.commands.send_msg_with_retry(contact, msg, max_attempts=msg_ack.max_attempts, flood_after=msg_ack.flood_after, max_flood_attempts=msg_ack.max_flood_attempts, timeout=timeout) if not res is None and not res.type == EventType.ERROR: res.payload["expected_ack"] = res.payload["expected_ack"].hex() if isinstance(contact, dict): sent = res.payload.copy() sent["type"] = "SENT_MSG" sent["recipient"] = contact["adv_name"] sent["text"] = msg sent["txt_type"] = 0 sent["sender"] = mc.self_info['name'] sent["name"] = sent["recipient"] await log_message(mc, sent) return not res is None msg_ack.max_attempts=3 msg_ack.flood_after=2 msg_ack.max_flood_attempts=1 async def set_scope (mc, scope) : if not set_scope.has_scope: return None if scope == "None" or scope == "0" or scope == "clear" or scope == "": scope = "*" if set_scope.current_scope == scope: return scope res = await mc.commands.set_flood_scope(scope) if res is None : return None if res.type == EventType.ERROR: if "error_code" in res.payload and res.payload["error_code"] == 1: #unsupported set_scope.has_scope = False return None set_scope.current_scope = scope return scope set_scope.has_scope = True set_scope.current_scope = None async def get_channel (mc, chan) : if not chan.isnumeric(): return await get_channel_by_name(mc, chan) nb = int(chan) if hasattr(mc, 'channels') and nb < len(mc.channels) : return mc.channels[nb] res = await mc.commands.get_channel(nb) if res.type == EventType.ERROR: return None info = res.payload info["channel_secret"] = info["channel_secret"].hex() return info async def set_channel (mc, chan, name, key=None): if isinstance(chan, str): if chan.isnumeric(): nb = int(chan) else: c = await get_channel_by_name(mc, chan) if c is None: return None nb = c['channel_idx'] elif isinstance(chan, int): nb = chan else: return None res = await mc.commands.set_channel(nb, name, key) if res.type == EventType.ERROR: return None res = await mc.commands.get_channel(nb) if res.type == EventType.ERROR: return None info = res.payload info["channel_secret"] = info["channel_secret"].hex() if hasattr(mc,'channels') : mc.channels[nb] = info return info async def get_channel_by_name (mc, name): if not hasattr(mc, 'channels') : await get_channels(mc) for c in mc.channels: if c['channel_name'] == name: return c return None async def get_contacts (mc, anim=False, lastomod=0, timeout=5) : if mc._contacts: return if anim: print("Fetching contacts ", end="", flush=True) await mc.commands.get_contacts_async() futures = [] contact_nb = 0 for event_type in [EventType.ERROR, EventType.NEXT_CONTACT, EventType.CONTACTS] : future = asyncio.create_task( mc.wait_for_event(event_type, {}, timeout=timeout) ) futures.append(future) while True: # Wait for the first event to complete or all to timeout done, pending = await asyncio.wait( futures, timeout=timeout, return_when=asyncio.FIRST_COMPLETED ) # Check if any future completed successfully if len(done) == 0: logger.debug("Timeout while getting contacts") for future in pending: # cancel all futures future.cancel() return None for future in done: event = await future if event: if event.type == EventType.NEXT_CONTACT: if anim: contact_nb = contact_nb+1 print(".", end="", flush=True) else: # Done or Error ... cancel pending and return if anim: if event.type == EventType.CONTACTS: print ((len(event.payload)-contact_nb)*"." + " Done") else : print(" Error") for future in pending: future.cancel() return event futures = [] for future in pending: # put back pending futures.append(future) future = asyncio.create_task( # and recreate NEXT_CONTACT mc.wait_for_event(EventType.NEXT_CONTACT, {}, timeout) ) futures.append(future) async def get_channels (mc, anim=False) : if hasattr(mc, 'channels') : return mc.channels if anim: print("Fetching channels ", end="", flush=True) ch = 0; mc.channels = [] while True: res = await mc.commands.get_channel(ch) if res.type == EventType.ERROR: break info = res.payload info["channel_secret"] = info["channel_secret"].hex() mc.channels.append(info) ch = ch + 1 if anim: print(".", end="", flush=True) if anim: print (" Done") return mc.channels async def print_trace_to (mc, contact): path = contact["out_path"] path_len = contact["out_path_len"] path_hash_len = await mc.commands.get_path_hash_mode() + 1 trace = "" if path_len == -1: print ("No path to destination") return if path_hash_len == 3: # will request a path with hash_len = 2 path_hash_len = 2 new_path = "" for i in range(0, path_len): new_path = new_path + path[6*(path_len-i-1):6*(path_len-i-1)+4] path = new_path if contact["type"] == 2 or contact["type"] == 3: # repeater or room, can trace to the contact itself trace = contact["public_key"][0:2*path_hash_len] for i in range(0, path_len): elem = path[2*path_hash_len*(path_len-i-1):2*path_hash_len*(path_len-i)] trace = elem if trace=="" else f"{elem}{trace}{elem}" if path_hash_len >= 2: trace = trace + ":1" await next_cmd(mc, ["trace", trace]) async def discover_path(mc, contact): await mc.ensure_contacts() timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.send_path_discovery_sync(contact, timeout) if res is None: return {"error": "timeout"} else : return res.payload async def print_disc_trace_to (mc, contact): p = await discover_path(mc, contact) if p is None: print("Error discovering path") return if "error" in p: print("Timeout discovering path") return inp = p["in_path"] outp = p["out_path"] inp_l = int(len(inp)/2) outp_l = int(len(outp)/2) trace = "" for i in range(0, outp_l): elem = outp[2*i:2*(i+1)] trace = elem if trace == "" else f"{trace},{elem}" if contact["type"] == 2 or contact["type"] == 3: # repeater or room, can trace to the contact itself elem = contact["public_key"][0:2] trace = elem if trace == "" else f"{trace},{elem}" for i in range(0, inp_l): elem = inp[2*i:2*(i+1)] if trace == "": trace = elem elif trace[-2:] != elem: trace = f"{trace},{elem}" logger.info(f"Trying {trace}") await next_cmd(mc, ["trace", trace]) async def get_contact_from_arg(mc, arg): contact = None await mc.ensure_contacts() # first try with key prefix try: # try only if its a valid hex int(arg ,16) contact = mc.get_contact_by_key_prefix(arg) except ValueError: pass if contact is None: # try by name contact = mc.get_contact_by_name(arg) return contact async def next_cmd(mc, cmds, json_output=False): """ process next command """ global ARROW_HEAD, SLASH_START, SLASH_END, INVERT_SLASH try : argnum = 0 if cmds[0].startswith("?") : # get some help get_help_for(cmds[0][1:], context="line") return cmds[argnum+1:] if cmds[0].startswith(".") : # override json_output json_output = True cmd = cmds[0][1:] else: cmd = cmds[0] match cmd : case "help" : command_help() case "ver" | "query" | "v" | "q": res = await mc.commands.send_device_query() logger.debug(res) if res.type == EventType.ERROR : print(f"ERROR: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else : print("Device info :") if res.payload["fw ver"] >= 3: print(f" Model: {res.payload['model']}") print(f" Version: {res.payload['ver']}") print(f" Build date: {res.payload['fw_build']}") if "repeat" in res.payload : print(f" Repeat: {'on' if res.payload['repeat'] else 'off'}") else : print(f" Firmware version : {res.payload['fw ver']}") case "clock" : if len(cmds) > 1 and cmds[1] == "sync" : argnum=1 res = await mc.commands.set_time(int(time.time())) logger.debug(res) if res.type == EventType.ERROR: if res.payload["error_code"] == 6 : if json_output: print(json.dumps({"ok": "No sync needed"})) else: print("No time sync needed") elif json_output : print(json.dumps({"error" : "Error syncing time"})) else: print(f"Error syncing time: {res}") elif json_output : res.payload["ok"] = "time synced" print(json.dumps(res.payload, indent=4)) else : print("Time synced") else: res = await mc.commands.get_time() timestamp = res.payload["time"] if res.type == EventType.ERROR: print(f"Error getting time: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else : print('Current time :' f' {datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")}' f' ({timestamp})') case "sync_time"|"clock sync"|"st": # keep if for the st shortcut res = await mc.commands.set_time(int(time.time())) logger.debug(res) if res.type == EventType.ERROR: if res.payload["error_code"] == 6 : if json_output: print(json.dumps({"ok": "No sync needed"})) else: print("No time sync needed") elif json_output : print(json.dumps({"error" : "Error syncing time"})) else: print(f"Error syncing time: {res}") elif json_output : res.payload["ok"] = "time synced" print(json.dumps(res.payload, indent=4)) else: print("Time synced") case "time" : argnum = 1 res = await mc.commands.set_time(cmds[1]) logger.debug(res) if res.type == EventType.ERROR: if json_output : print(json.dumps({"error" : "Error setting time"})) else: print (f"Error setting time: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("Time set") case "apply_to"|"at": argnum = 2 await apply_command_to_contacts(mc, cmds[1], cmds[2], json_output=json_output) case "set": argnum = 2 match cmds[1]: case "help" : argnum = 1 get_help_for("set") case "max_flood_attempts": msg_ack.max_flood_attempts=int(cmds[2]) case "max_attempts": msg_ack.max_attempts=int(cmds[2]) case "flood_after": msg_ack.flood_after=int(cmds[2]) case "classic_prompt": interactive_loop.classic = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "arrow_head": ARROW_HEAD = cmds[2] case "slash_start": SLASH_START = cmds[2] case "slash_end": SLASH_END = cmds[2] case "invert_slash": INVERT_SLASH = cmds[2] == "on" case "color" : process_event_message.color = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "print_timestamp" : process_event_message.timestamp = cmds[2] if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "print_snr" : process_event_message.print_snr = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "json_log_rx" : handle_log_rx.json_log_rx = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "channel_echoes" : handle_log_rx.channel_echoes = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "advert_echoes" : handle_log_rx.advert_echoes = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "echo_unk_chans" : handle_log_rx.echo_unk_chans = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "print_adverts" : handle_advert.print_adverts = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "print_path_updates" : handle_path_update.print_path_updates = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "print_new_contacts" : handle_new_contact.print_new_contacts = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "json_msgs" : handle_message.json_output = (cmds[2] == "on") if json_output : print(json.dumps({"cmd" : cmds[1], "param" : cmds[2]})) case "pin": res = await mc.commands.set_devicepin(cmds[2]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "radio": params=cmds[2].split(",") if len (params) > 4: repeat = params[4] == "repeat" or\ params[4] == "on" or\ params[4] == "1" or\ params[4] == "yes" res=await mc.commands.set_radio(params[0], params[1], params[2], params[3], repeat) else: res=await mc.commands.set_radio(params[0], params[1], params[2], params[3]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "path_hash_mode": mode = int(cmds[2]) if mode >= 3: logger.error(f"Can't set value to {mode}") else: res = await mc.commands.set_path_hash_mode(mode) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output: print(json.dumps(res.payload, indent=4)) else: print("ok") case "name": res = await mc.commands.set_name(cmds[2]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "tx": res = await mc.commands.set_tx_power(cmds[2]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "lat": if "adv_lon" in mc.self_info : lon = mc.self_info['adv_lon'] else: lon = 0 lat = float(cmds[2]) res = await mc.commands.set_coords(lat, lon) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "lon": if "adv_lat" in mc.self_info : lat = mc.self_info['adv_lat'] else: lat = 0 lon = float(cmds[2]) res = await mc.commands.set_coords(lat, lon) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "coords": params=cmds[2].split(",") res = await mc.commands.set_coords(\ float(params[0]),\ float(params[1])) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "private_key": params=bytes.fromhex(cmds[2]) res = await mc.commands.import_private_key(params) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "tuning": params=cmds[2].commands.split(",") res = await mc.commands.set_tuning( int(params[0]), int(params[1])) logger.debug(res) if res.type == EventType.ERROR: print(f"Error: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("ok") case "manual_add_contacts": mac = (cmds[2] == "on") or (cmds[2] == "true") or (cmds[2] == "yes") or (cmds[2] == "1") res = await mc.commands.set_manual_add_contacts(mac) if res.type == EventType.ERROR: print(f"Error : {res}") else : print(f"manual add contact: {mac}") case "autoadd_config": cmds[2] = cmds[2].lower() # lowercase argument try: flags = int(cmds[2], 0) except ValueError: # not int, find bits from string flags = 0 if "ov" in cmds[2]: # overwrite flags = flags|0x01 if "cha" in cmds[2] or "cli" in cmds[2]: # chat / client flags = flags|0x02 if "rep" in cmds[2] or "rpt" in cmds[2]: # repeater flags = flags|0x04 if "roo" in cmds[2]: # room flags = flags|0x08 if "sen" in cmds[2]: # sensor flags = flags|0x10 res = await mc.commands.set_autoadd_config(flags) if res.type == EventType.ERROR: print(f"Error : {res}") case "multi_acks": ma = (cmds[2] == "on") or (cmds[2] == "true") or (cmds[2] == "yes") or (cmds[2] == "1") res = await mc.commands.set_multi_acks(ma) if res.type == EventType.ERROR: print(f"Error : {res}") else : print(f"multi_acks: {ma}") case "auto_update_contacts": auc = (cmds[2] == "on") or (cmds[2] == "true") or (cmds[2] == "yes") or (cmds[2] == "1") mc.auto_update_contacts=auc case "telemetry_mode_base": if (cmds[2] == "2") or (cmds[2] == "all") or (cmds[2] == "yes") or (cmds[2] == "on") : mode = 2 elif (cmds[2] == "1") or (cmds[2] == "selected") or (cmds[2] == "dev") : mode = 1 else : mode = 0 res = await mc.commands.set_telemetry_mode_base(mode) if res.type == EventType.ERROR: print(f"Error : {res}") else: print(f"telemetry mode: {mode}") case "telemetry_mode_loc": if (cmds[2] == "2") or (cmds[2].startswith("al")) or (cmds[2] == "yes") or (cmds[2] == "on") : mode = 2 elif (cmds[2] == "1") or (cmds[2] == "selected") or (cmds[2].startswith("dev")) : mode = 1 else : mode = 0 res = await mc.commands.set_telemetry_mode_loc(mode) if res.type == EventType.ERROR: print(f"Error : {res}") else: print(f"telemetry mode for location: {mode}") case "telemetry_mode_env": if (cmds[2] == "2") or (cmds[2].startswith("al")) or (cmds[2] == "yes") or (cmds[2] == "on") : mode = 2 elif (cmds[2] == "1") or (cmds[2] == "selected") or (cmds[2].startswith("dev")) : mode = 1 else : mode = 0 res = await mc.commands.set_telemetry_mode_env(mode) if res.type == EventType.ERROR: print(f"Error : {res}") else: print(f"telemetry mode for env: {mode}") case "advert_loc_policy": if (cmds[2] == "1") or (cmds[2] == "share") : policy = 1 else : policy = 0 res = await mc.commands.set_advert_loc_policy(policy) if res.type == EventType.ERROR: print(f"Error : {res}") else: print(f"Policy for adv_loc: {policy}") case "default_scope": res = await mc.commands.set_default_flood_scope(cmds[2]) if res.type == EventType.ERROR: print(f"Error : {res}") else: print("Default scope set") case _: # custom var if cmds[1].startswith("_") : vname = cmds[1][1:] else: vname = cmds[1] res = await mc.commands.set_custom_var(vname, cmds[2]) if res.type == EventType.ERROR: print(f"Error : {res}") elif json_output : print(json.dumps({"result" : "set", "var" : vname, "value" : cmds[2]})) else : print(f"Var {vname} set to {cmds[2]}") case "get" : argnum = 1 match cmds[1]: case "help": get_help_for("get") case "max_flood_attempts": if json_output : print(json.dumps({"max_flood_attempts" : msg_ack.max_flood_attempts})) else: print(f"max_flood_attempts: {msg_ack.max_flood_attempts}") case "flood_after": if json_output : print(json.dumps({"flood_after" : msg_ack.flood_after})) else: print(f"flood_after: {msg_ack.flood_after}") case "classic_prompt": if json_output : print(json.dumps({"classic_prompt" : interactive_loop.classic})) else: print(f"{'on' if interactive_loop.classic else 'off'}") case "json_msgs": if json_output : print(json.dumps({"json_msgs" : handle_message.json_output})) else: print(f"{'on' if handle_message.json_output else 'off'}") case "color": if json_output : print(json.dumps({"color" : process_event_message.color})) else: print(f"{'on' if process_event_message.color else 'off'}") case "print_timestamp": if json_output : print(json.dumps({"timestamp" : process_event_message.timestamp})) else: print(f"{process_event_message.timestamp}") case "json_log_rx": if json_output : print(json.dumps({"json_log_rx" : handle_log_rx.json_log_rx})) else: print(f"{'on' if handle_log_rx.json_log_rx else 'off'}") case "channel_echoes": if json_output : print(json.dumps({"channel_echoes" : handle_log_rx.channel_echoes})) else: print(f"{'on' if handle_log_rx.channel_echoes else 'off'}") case "advert_echoes": if json_output : print(json.dumps({"advert_echoes" : handle_log_rx.channel_echoes})) else: print(f"{'on' if handle_log_rx.advert_echoes else 'off'}") case "echo_unk_chans": if json_output : print(json.dumps({"echo_unk_chans" : handle_log_rx.echo_unk_chans})) else: print(f"{'on' if handle_log_rx.echo_unk_chans else 'off'}") case "print_adverts": if json_output : print(json.dumps({"print_adverts" : handle_advert.print_adverts})) else: print(f"{'on' if handle_advert.print_adverts else 'off'}") case "print_path_updates": if json_output : print(json.dumps({"print_path_updates" : handle_path_update.print_path_updates})) else: print(f"{'on' if handle_path_update.print_path_updates else 'off'}") case "print_new_contacts": if json_output : print(json.dumps({"print_new_contacts" : handle_new_contact.print_new_contacts})) else: print(f"{'on' if handle_new_contact.print_new_contacts else 'off'}") case "print_snr": if json_output : print(json.dumps({"print_snr" : process_event_message.print_snr})) else: print(f"{'on' if process_event_message.print_snr else 'off'}") case "name": await mc.commands.send_appstart() if json_output : print(json.dumps(mc.self_info["name"])) else: print(mc.self_info["name"]) case "tx": await mc.commands.send_appstart() if json_output : print(json.dumps(mc.self_info["tx_power"])) else: print(mc.self_info["tx_power"]) case "coords": await mc.commands.send_appstart() if json_output : print(json.dumps({"lat": mc.self_info["adv_lat"], "lon":mc.self_info["adv_lon"]})) else: print(f"{mc.self_info['adv_lat']},{mc.self_info['adv_lon']}") case "lat": await mc.commands.send_appstart() if json_output : print(json.dumps({"lat": mc.self_info["adv_lat"]})) else: print(f"{mc.self_info['adv_lat']}") case "lon": await mc.commands.send_appstart() if json_output : print(json.dumps({"lon": mc.self_info["adv_lon"]})) else: print(f"{mc.self_info['adv_lon']}") case "radio": await mc.commands.send_appstart() radio = {"radio_freq": mc.self_info["radio_freq"], "radio_bw": mc.self_info["radio_bw"], "radio_sf": mc.self_info["radio_sf"], "radio_cr": mc.self_info["radio_cr"]} res = await mc.commands.send_device_query() if res.type != EventType.ERROR : if "repeat" in res.payload: radio["repeat"] = res.payload["repeat"] if json_output : print(json.dumps(radio)) else: print(f"{radio['radio_freq']},{radio['radio_bw']},{radio['radio_sf']},{radio['radio_cr']}",end="") if "repeat" in radio: print(f",{'on' if radio['repeat'] else 'off'}") else: print("") case "repeat": res = await mc.commands.send_device_query() logger.debug(res) if res.type == EventType.ERROR : print(f"ERROR: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else : if "repeat" in res.payload : print(f"Repeat: {'on' if res.payload['repeat'] else 'off'}") else: print("Can't repeat") case "path_hash_mode": res = await mc.commands.send_device_query() logger.debug(res) if res.type == EventType.ERROR : print(f"ERROR: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else : if "path_hash_mode" in res.payload : print(f"{res.payload['path_hash_mode']}") else: print("Not available") case "bat" : res = await mc.commands.get_bat() logger.debug(res) if res.type == EventType.ERROR: print(f"Error getting bat {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print(f"Battery level : {res.payload['level']}") case "private_key": res = await mc.commands.export_private_key() logger.debug(res) if res.type == EventType.ERROR: print(f"Error exporting private key {res}") elif json_output : res.payload["private_key"] = res.payload["private_key"].hex() print(json.dumps(res.payload)) else: print(f"Private key: {res.payload['private_key'].hex()}") case "fstats" : res = await mc.commands.get_bat() logger.debug(res) if res.type == EventType.ERROR or not "used_kb" in res.payload: print(f"Error getting fs stats {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print(f"Using {res.payload['used_kb']}kB of {res.payload['total_kb']}kB") case "multi_acks" : await mc.commands.send_appstart() if json_output : print(json.dumps({"multi_acks" : mc.self_info["multi_acks"]})) else : print(f"multi_acks: {mc.self_info['multi_acks']}") case "manual_add_contacts" : await mc.commands.send_appstart() if json_output : print(json.dumps({"manual_add_contacts" : mc.self_info["manual_add_contacts"]})) else : print(f"manual_add_contacts: {mc.self_info['manual_add_contacts']}") case "autoadd_config" : res = await mc.commands.get_autoadd_config() if res is None or res.type == EventType.ERROR: logger.error("Can't get autoadd_config") else : print(f"0x{res.payload['config']:02x}") case "telemetry_mode_base" : await mc.commands.send_appstart() if json_output : print(json.dumps({"telemetry_mode_base" : mc.self_info["telemetry_mode_base"]})) else : print(f"telemetry_mode_base: {mc.self_info['telemetry_mode_base']}") case "telemetry_mode_loc" : await mc.commands.send_appstart() if json_output : print(json.dumps({"telemetry_mode_loc" : mc.self_info["telemetry_mode_loc"]})) else : print(f"telemetry_mode_loc: {mc.self_info['telemetry_mode_loc']}") case "telemetry_mode_env" : await mc.commands.send_appstart() if json_output : print(json.dumps({"telemetry_mode_env" : mc.self_info["telemetry_mode_env"]})) else : print(f"telemetry_mode_env: {mc.self_info['telemetry_mode_env']}") case "advert_loc_policy" : await mc.commands.send_appstart() if json_output : print(json.dumps({"advert_loc_policy" : mc.self_info["adv_loc_policy"]})) else : print(f"advert_loc_policy: {mc.self_info['adv_loc_policy']}") case "auto_update_contacts" : if json_output : print(json.dumps({"auto_update_contacts" : mc.auto_update_contacts})) else : print(f"auto_update_contacts: {'on' if mc.auto_update_contacts else 'off'}") case "custom" : res = await mc.commands.get_custom_vars() logger.debug(res) if res.type == EventType.ERROR : if json_output : print(json.dumps(res)) else : logger.error("Couldn't get custom variables") else : print(json.dumps(res.payload, indent=4)) case "stats_core": res = await mc.commands.get_stats_core() logger.debug(res) if res.type == EventType.ERROR: logger.error("Couldn't get stats") else: print(json.dumps(res.payload, indent=4)) case "stats_radio": res = await mc.commands.get_stats_radio() logger.debug(res) if res.type == EventType.ERROR: logger.error("Couldn't get stats") else: print(json.dumps(res.payload, indent=4)) case "stats_packets": res = await mc.commands.get_stats_packets() logger.debug(res) if res.type == EventType.ERROR: logger.error("Couldn't get stats") else: print(json.dumps(res.payload, indent=4)) case "stats"|"status": stats = {} res = await mc.commands.get_stats_core() stats.update(res.payload) if res.type == EventType.ERROR: logger.error("Couldn't get core stats") else: stats.update(res.payload) res = await mc.commands.get_stats_radio() if res.type == EventType.ERROR: logger.error("Couldn't get radio stats") else: stats.update(res.payload) res = await mc.commands.get_stats_packets() if res.type == EventType.ERROR: logger.error("Couldn't get packets stats") else: stats.update(res.payload) print(json.dumps(stats, indent=4)) case "allowed_repeat_freq" : res = await mc.commands.get_allowed_repeat_freq() print(json.dumps(res.payload)) case "default_scope" : res = await mc.commands.get_default_flood_scope() print(json.dumps(res.payload)) case _ : res = await mc.commands.get_custom_vars() logger.debug(res) if res.type == EventType.ERROR : if json_output : print(json.dumps(res)) else : logger.error(f"Couldn't get custom variables") else : try: if cmds[1].startswith("_"): vname = cmds[1][1:] else: vname = cmds[1] val = res.payload[vname] except KeyError: if json_output : print(json.dumps({"error" : "Unknown var", "var" : cmds[1]})) else : print(f"Unknown var {cmds[1]}") else: if json_output : print(json.dumps({"var" : vname, "value" : val})) else: print(val) case "self_telemetry" | "t": res = await mc.commands.get_self_telemetry() logger.debug(res) if res.type == EventType.ERROR: print(f"Error while requesting telemetry") elif res is None: if json_output : print(json.dumps({"error" : "Timeout waiting telemetry"})) else: print("Timeout waiting telemetry") else : print(json.dumps(res.payload, indent=4)) case "get_channel": argnum = 1 res = await get_channel(mc, cmds[1]) if res is None: print(f"Error while requesting channel info") else: print(res) case "get_channels"|"gc": res = await get_channels(mc) if json_output: print(json.dumps(res)) else: for c in mc.channels: if c["channel_name"] != "": print(f"{c['channel_idx']}: {c['channel_name']} [{c['channel_secret']}]") case "set_channel": argnum = 3 if cmds[2].startswith("#") or len(cmds) == 3: argnum = 2 res = await set_channel(mc, cmds[1], cmds[2]) elif len(cmds[3]) != 32: res = None else: res = await set_channel(mc, cmds[1], cmds[2], bytes.fromhex(cmds[3])) if res is None: print("Error setting channel") case "add_channel": argnum = 2 if cmds[1].startswith("#") or len(cmds) == 2: argnum = 1 res = await set_channel(mc, "", cmds[1]) elif len(cmds[2]) != 32: res = None else: res = await set_channel(mc, "", cmds[1], bytes.fromhex(cmds[2])) if res is None: print("Error adding channel") case "remove_channel": argnum = 1 res = await set_channel(mc, cmds[1], "", bytes.fromhex(16*"00")) if res is None: print("Error deleting channel") case "scope": argnum = 1 res = await set_scope(mc, cmds[1]) if res is None: print(f"Error while setting scope") case "reboot" : res = await mc.commands.reboot() logger.debug(res) if json_output : print(json.dumps(res.payload, indent=4)) case "msg" | "m" | "{" : # sends to a contact from name argnum = 2 dest = None if len(cmds[1]) >= 12: # possibly an hex prefix try: dest = bytes.fromhex(cmds[1]) except ValueError: dest = None if dest is None: dest = await get_contact_from_arg(mc, cmds[1]) if dest is None: if json_output : print(json.dumps({"error" : "unknown destination", "dest" : cmds[1]})) else: print(f"Unknown destination {cmds[1]}") else : res = await send_msg(mc, dest, cmds[2]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error sending message: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) case "chan"|"ch" : argnum = 2 if cmds[1].isnumeric() : nb = int(cmds[1]) else: chan = await get_channel_by_name(mc, cmds[1]) print (chan) nb = chan['channel_idx'] res = await send_chan_msg(mc, nb, cmds[2]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error sending message: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) case "public" | "dch" : # default chan argnum = 1 res = await send_chan_msg(mc, 0, cmds[1]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error sending message: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) case "cmd" | "c" | "[" : argnum = 2 dest = None if len(cmds[1]) == 12: # possibly an hex prefix try: dest = bytes.fromhex(cmds[1]) except ValueError: dest = None if dest is None: dest = await get_contact_from_arg(mc, cmds[1]) if dest is None: if json_output : print(json.dumps({"error" : "contact destination", "dest" : cmds[1]})) else: print(f"Unknown destination {cmds[1]}") else: res = await send_cmd(mc, dest, cmds[2]) logger.debug(res) if res.type == EventType.ERROR: print(f"Error sending cmd: {res}") elif json_output : print(res.payload) print(json.dumps(res.payload, indent=4)) case "trace" | "tr": argnum = 1 path = cmds[1] flags = None # by default compute flags from path (when comma separated) if not "," in path: if ":" in path: flags = int(path.split(":")[1]) path = path.split(":")[0] path = bytes.fromhex(path) # we can directly send a bytes res = await mc.commands.send_trace(path=path, flags=flags) if res and res.type != EventType.ERROR: tag= int.from_bytes(res.payload['expected_ack'], byteorder="little") timeout = res.payload["suggested_timeout"] / 1000 * 1.2 ev = await mc.wait_for_event(EventType.TRACE_DATA, attribute_filters={"tag": tag}, timeout=timeout) if ev is None: if json_output: print(json.dumps({"error" : "timeout waiting trace"})) else : print(f"Timeout waiting trace for path {cmds[1]}") elif ev.type == EventType.ERROR: if json_output: print(json.dumps(ev.payload)) else : print("Error waiting trace") else: if json_output: print(json.dumps(ev.payload, indent=2)) else : color = process_event_message.color classic = interactive_loop.classic or not color print(" ", end="") for t in ev.payload["path"]: if classic : print("โ†’",end="") else: print(f"{ANSI_INVERT}", end="") snr = t['snr'] if color: if snr >= 10 : print(ANSI_BGREEN, end="") elif snr <= 0: print(ANSI_BRED, end="") else : print(ANSI_BGRAY, end="") print(f"{snr:.2f}",end="") if classic : print("โ†’",end="") else : print(f"{ANSI_NORMAL}{ARROW_HEAD}",end="") if color: print(ANSI_END, end="") if "hash" in t: print(f"[{t['hash']}]",end="") else: print() case "login" | "l" : argnum = 2 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: # still none ? contact not found if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: password = cmds[2] if password == "$": sess = PromptSession("Password: ", is_password=True) password = await sess.prompt_async() timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.send_login_sync(contact, password, timeout = timeout) logger.debug(res) if res is None: print("Login failed : Error or Timeout waiting response") elif json_output : if res.type == EventType.LOGIN_SUCCESS: print(json.dumps({"login_success" : True}, indent=4)) else: print(json.dumps({"login_success" : False, "error" : "login failed"}, indent=4)) else: if res.type == EventType.LOGIN_SUCCESS: print("Login success") else: print("Login failed") case "logout" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: res = await mc.commands.send_logout(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error while logout: {res}") elif json_output : print(json.dumps(res.payload)) else: print("Logout ok") case "contact_timeout" : argnum = 2 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: contact["timeout"] = float(cmds[2]) case "disc_path" | "dp" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: res = await discover_path(mc, contact) if res is None: print(f"Error while discovering path") else: if json_output : print(json.dumps(res, indent=4)) else: if "error" in res : print("Timeout while discovering path") else: outp = res['out_path'] outp = outp if outp != "" else "direct" inp = res['in_path'] inp = inp if inp != "" else "direct" print(f"Path for {contact['adv_name']}: out {outp}, in {inp}") case "node_discover"|"nd" : argnum = 1 prefix_only = True if len(cmds) == 1: argnum = 0 types = 0xFF else: try: # try to decode type as int types = int(cmds[1]) except ValueError: if "all" in cmds[1]: types = 0xFF else : types = 0 if "rep" in cmds[1] or "rpt" in cmds[1]: types = types | 4 if "cli" in cmds[1] or "comp" in cmds[1]: types = types | 2 if "room" in cmds[1]: types = types | 8 if "sens" in cmds[1]: types = types | 16 if "full" in cmds[1]: prefix_only = False res = await mc.commands.send_node_discover_req(types, prefix_only=prefix_only) if res is None or res.type == EventType.ERROR: print("Error sending discover request") else: exp_tag = res.payload["tag"].to_bytes(4, "little").hex() dn = [] while True: r = await mc.wait_for_event( EventType.DISCOVER_RESPONSE, attribute_filters={"tag":exp_tag}, timeout = 5 ) if r is None or r.type == EventType.ERROR: break else: dn.append(r.payload) if json_output: print(json.dumps(dn)) else: await mc.ensure_contacts() print(f"Discovered {len(dn)} nodes:") for n in dn: try : name = f"{n['pubkey'][0:6]} {mc.get_contact_by_key_prefix(n['pubkey'])['adv_name']}" except TypeError: name = n["pubkey"][0:16] if n['node_type'] >= len(CONTACT_TYPENAMES): type = f"t:{n['node_type']}" else: type = CONTACT_TYPENAMES[n['node_type']] print(f" {name:28} {type:>4} SNR: {n['SNR_in']:6,.2f}->{n['SNR']:6,.2f} RSSI: ->{n['RSSI']:4}") case "req_regions"|"rr": argnum = 1 await mc.ensure_contacts() contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_regions_sync(contact, timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : if json_output : print(json.dumps({"repeater": contact["adv_name"]}, {"regions": res})) else : print(f"{contact['adv_name']} repeats {res}") case "req_owner"|"ro": argnum = 1 await mc.ensure_contacts() contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_owner_sync(contact, timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : if json_output: print(json.dumps(res)) else: if res["owner"] == "": print(f"{res['name']} has no owner set") else: print(f"{res['name']}ย is owned by {res['owner']}") case "req_clock": argnum = 1 await mc.ensure_contacts() contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_basic_sync(contact, timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : print(int.from_bytes(bytes.fromhex(res["data"][0:8]), byteorder="little", signed=False)) case "req_telemetry"|"rt" : argnum = 1 await mc.ensure_contacts() contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_telemetry_sync(contact, timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : print(json.dumps({ "name": contact["adv_name"], "pubkey_pre": contact["public_key"][0:16], "lpp": res, }, indent = 4)) case "req_status"|"rs" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_status_sync(contact, timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : print(json.dumps(res, indent=4)) case "req_mma" | "rm": argnum = 3 await mc.ensure_contacts() contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: if cmds[2][-1] == "s": from_secs = int(cmds[2][0:-1]) elif cmds[2][-1] == "m": from_secs = int(cmds[2][0:-1]) * 60 elif cmds[2][-1] == "h": from_secs = int(cmds[2][0:-1]) * 3600 else : from_secs = int(cmds[2]) * 60 # same as tdeck if cmds[3][-1] == "s": to_secs = int(cmds[3][0:-1]) elif cmds[3][-1] == "m": to_secs = int(cmds[3][0:-1]) * 60 elif cmds[3][-1] == "h": to_secs = int(cmds[3][0:-1]) * 3600 else : to_secs = int(cmds[3]) * 60 timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_mma_sync(contact, from_secs, to_secs, timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : print(json.dumps(res, indent=4)) case "req_acl"|"ra" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_acl_sync(contact, timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : if json_output: print(json.dumps(res, indent=4)) else: for e in res: name = f" [{e['key']}] " ct = mc.get_contact_by_key_prefix(e['key']) if ct is None: if mc.self_info["public_key"].startswith(e['key']): name += f"self" else: name += f"{ct['adv_name']}" print(f"{name}{ANSI_START}42G: {e['perm']:02x}") case "req_neighbours"|"rn" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.fetch_all_neighbours(contact, timeout=timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting data"})) else: print("Error getting data") else : if json_output: print(json.dumps(res, indent=4)) else: width = os.get_terminal_size().columns print(f"Got {res['results_count']} neighbours out of {res['neighbours_count']} from {contact['adv_name']}:") for n in res['neighbours']: ct = mc.get_contact_by_key_prefix(n["pubkey"]) if ct and width > 60 : name = f"[{n['pubkey'][0:8]}] {ct['adv_name']}" name = f"{name:30}{ANSI_START}31G" elif ct : name = f"{ct['adv_name']}" name = f"{name:20}{ANSI_START}21G" elif width > 60: name = f"[{n['pubkey']}]{ANSI_START}31G" else: name = f"[{n['pubkey']}]{ANSI_START}21G" t_s = n['secs_ago'] time_ago = f"{t_s}s" if t_s / 86400 >= 1 : # result in days time_ago = f"{int(t_s/86400)}d ago{f' ({time_ago})' if width > 62 else ''}" elif t_s / 3600 >= 1 : # result in days time_ago = f"{int(t_s/3600)}h ago{f' ({time_ago})' if width > 62 else ''}" elif t_s / 60 >= 1 : # result in min time_ago = f"{int(t_s/60)}m ago{f' ({time_ago})' if width > 62 else ''}" print(f" {name} {time_ago}, {n['snr']}dB{' SNR' if width > 66 else ''}") case "req_binary" : argnum = 2 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "unknown contact"})) else: print(f"Unknown contact {cmds[1]}") else: timeout = 0 if not "timeout" in contact else contact["timeout"] res = await mc.commands.req_binary(contact, bytes.fromhex(cmds[2]), timeout) if res is None : if json_output : print(json.dumps({"error" : "Getting binary data"})) else: print("Error getting binary data") else : print(json.dumps(res)) case "contacts" | "list" | "lc": await mc.ensure_contacts(follow=True) res = mc.contacts if json_output : print(json.dumps(res, indent=4)) else : for c in res.items(): if c[1]['out_path_len'] == -1: path_str = "Flood" elif c[1]['out_path_len'] == 0: path_str = "0 hop" else: phs = c[1]['out_path_hash_mode'] + 1 plen = c[1]['out_path_len'] path_str_in = c[1]['out_path'] path_str = path_str_in[:2*phs] for i in range(1,plen): path_str = path_str + "," + path_str_in[i*phs*2:(i+1)*2*phs] #path_str = f"{c[1]['out_path']}:{c[1]['out_path_hash_mode']}" print(f"{c[1]['adv_name']:30} ", end="", flush=True) print(f"{ANSI_START}34G", end="", flush=True) print(f"{CONTACT_TYPENAMES[c[1]['type']]:4} ", end="", flush=True) print(f"{c[1]['public_key'][:12]} ย {path_str}") print(f"> {len(mc.contacts)} contacts in device") case "reload_contacts" | "rc": await mc.commands.get_contacts() res = mc.contacts if json_output : print(json.dumps(res, indent=4)) else : for c in res.items(): print(c[1]["adv_name"]) print(f"> {len(mc.contacts)} contacts in device") case "pending_contacts": if json_output: print(json.dumps(mc.pending_contacts, indent=4)) else: for c in mc.pending_contacts.items(): print(f"{c[1]['adv_name']}: {c[1]['public_key']}") case "flush_pending": mc.flush_pending_contacts() case "add_pending": argnum = 1 contact = mc.pop_pending_contact(cmds[1]) if contact is None: # try to find by name key = None for c in mc.pending_contacts.items(): if c[1]['adv_name'] == cmds[1]: key = c[1]['public_key'] contact = mc.pop_pending_contact(key) break if contact is None: if json_output: print(json.dumps({"error":"Contact does not exist"})) else: logger.error(f"Contact {cmds[1]} does not exist") else: res = await mc.commands.add_contact(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error adding contact: {res}") else: mc.contacts[contact["public_key"]]=contact if json_output : print(json.dumps(res.payload, indent=4)) case "path": argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: path = contact["out_path"] path_len = contact["out_path_len"] if json_output : print(json.dumps({"adv_name" : contact["adv_name"], "out_path_hash_len" : contact["out_path_hash_len"], "out_path_len" : path_len, "out_path" : path})) else: if (path_len == 0) : print("0 hop") elif (path_len == -1) : print("Flood") else: phs = contact['out_path_hash_mode']+1 path_str = path[:2*phs] for i in range(1,path_len): path_str = path_str + "," + path[i*phs*2:(i+1)*2*phs] print(path_str) case "contact_info" | "ci": argnum = 1 res = await mc.ensure_contacts(follow=True) contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: print(json.dumps(contact, indent=4)) case "add_contact" : argnum = 3 # key type name contact = { "public_key": cmds[1], "type" : int (cmds[2]), "flags" : 0, "out_path_len" : 0, "out_path" : "", "out_path_hash_mode" : 0, "adv_name" : cmds[3], "adv_lat" : 0, "adv_lon" : 0, "last_advert" : 0, } try: res = await mc.commands.update_contact(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error adding contact: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) except ValueError: print(f"Error ! Command format add_contact key type namez") case "change_path" | "cp": argnum = 2 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: path = cmds[2] if path == "0": path = "" elif "," in path and not ":" in path: # deduce path_hash_size from first hash path_hash_size = int(len(path.split(",")[0])/2) path = path + f":{path_hash_size-1}" path = path.replace(",","") try: res = await mc.commands.change_contact_path(contact, path) logger.debug(res) if res.type == EventType.ERROR: print(f"Error setting path: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) except ValueError: print(f"Bad path format {cmds[2]}") case "change_flags" | "cf": argnum = 2 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: res = await mc.commands.change_contact_flags(contact, int(cmds[2])) logger.debug(res) if res.type == EventType.ERROR: print(f"Error setting path: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) case "reset_path" | "rp" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: res = await mc.commands.reset_path(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error resetting path: {res}") else: if json_output : print(json.dumps(res.payload, indent=4)) contact["out_path"] = "" contact["out_path_len"] = -1 case "advert_path" | "ap": argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: # search in pending contacts for c in mc.pending_contacts.items(): if c[1]['adv_name'] == cmds[1] or \ c[1]['public_key'].startswith(cmds[1]): contact = c[1]['public_key'] if contact is None: contact = cmds[1] # use input from user res = await mc.commands.get_advert_path(contact) logger.debug(res) if res is None: logger.error("couldn't send cmd") elif res.type == EventType.ERROR: print(res) else: if json_output: print(json.dumps(res.payload)) else : path_len = res.payload['path_len'] if (path_len == 0) : print("0 hop") elif (path_len == -1) : print("Flood") else: phs = res.payload['path_hash_mode']+1 path = res.payload['path'] path_str = path[:2*phs] for i in range(1,path_len): path_str = path_str + "," + path[i*phs*2:(i+1)*2*phs] print(path_str) case "share_contact" | "sc": argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: res = await mc.commands.share_contact(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error while sharing contact: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) case "export_contact"|"ec": argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: res = await mc.commands.export_contact(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error exporting contact: {res}") else: if json_output : print(json.dumps(res.payload)) else : print(res.payload['uri']) case "import_contact"|"ic": argnum = 1 if cmds[1].startswith("meshcore://") : res = await mc.commands.import_contact(bytes.fromhex(cmds[1][11:])) logger.debug(res) if res.type == EventType.ERROR: print(f"Error while importing contact: {res}") else: logger.info("Contact successfully added") await mc.commands.get_contacts() case "upload_contact" | "uc" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: res = await mc.commands.export_contact(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error exporting contact: {res}") else : resp = requests.post("https://map.meshcore.dev/api/v1/nodes", json = {"links": [res.payload['uri']]}) if json_output : print(json.dumps({"response", str(resp)})) else : print(resp) case "card" : res = await mc.commands.export_contact() logger.debug(res) if res.type == EventType.ERROR: print(f"Error exporting contact: {res}") elif json_output : print(json.dumps(res.payload)) else : print(res.payload['uri']) case "upload_card" : res = await mc.commands.export_contact() logger.debug(res) if res.type == EventType.ERROR: print(f"Error exporting contact: {res}") else : resp = requests.post("https://map.meshcore.dev/api/v1/nodes", json = {"links": [res.payload['uri']]}) if json_output : print(json.dumps({"response", str(resp)})) else : print(resp) case "remove_contact" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) if contact is None: if json_output : print(json.dumps({"error" : "contact unknown", "name" : cmds[1]})) else: print(f"Unknown contact {cmds[1]}") else: res = await mc.commands.remove_contact(contact) logger.debug(res) if res.type == EventType.ERROR: print(f"Error removing contact: {res}") else: if json_output : print(json.dumps(res.payload, indent=4)) del mc.contacts[contact["public_key"]] case "recv" | "r" : res = await mc.commands.get_msg() logger.debug(res) await process_event_message(mc, res, json_output) case "sync_msgs" | "sm": ret = True first = True if json_output : print("[", end="", flush=True) end="" else: end="\n" while ret: res = await mc.commands.get_msg() logger.debug(res) if res.type != EventType.NO_MORE_MSGS: if not first and json_output : print(",") ret = await process_event_message(mc, res, json_output,end=end) first = False if json_output : print("]") case "infos" | "i" : await mc.commands.send_appstart() print(json.dumps(mc.self_info,indent=4)) case "advert" | "a": res = await mc.commands.send_advert() logger.debug(res) if res.type == EventType.ERROR: print(f"Error sending advert: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("Advert sent") case "flood_advert" | "floodadv": res = await mc.commands.send_advert(flood=True) logger.debug(res) if res.type == EventType.ERROR: print(f"Error sending advert: {res}") elif json_output : print(json.dumps(res.payload, indent=4)) else: print("Advert sent") case "sleep" | "s" : argnum = 1 await asyncio.sleep(int(cmds[1])) case "wait_key" | "wk" : try : ps = PromptSession() if json_output: await ps.prompt_async() else: await ps.prompt_async("Press Enter to continue ...\n") except (EOFError, KeyboardInterrupt, asyncio.CancelledError): pass case "wait_msg" | "wm" : ev = await mc.wait_for_event(EventType.MESSAGES_WAITING) if ev is None: print("Timeout waiting msg") else: res = await mc.commands.get_msg() logger.debug(res) await process_event_message(mc, res, json_output) case "trywait_msg" | "wmt" : argnum = 1 if await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=int(cmds[1])) : res = await mc.commands.get_msg() logger.debug(res) await process_event_message(mc, res, json_output) case "wmt8"|"]": if await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=8) : res = await mc.commands.get_msg() logger.debug(res) await process_event_message(mc, res, json_output) case "wait_ack" | "wa" | "}": res = await mc.wait_for_event(EventType.ACK, timeout = 5) logger.debug(res) if res is None: if json_output : print(json.dumps({"error" : "Timeout waiting ack"})) else: print("Timeout waiting ack") elif json_output : print(json.dumps(res.payload, indent=4)) else : print("Msg acked") case "msgs_subscribe" | "ms" : await subscribe_to_msgs(mc, json_output=json_output) case "interactive" | "im" | "chat" : await interactive_loop(mc) case "chat_to" | "imto" | "to" : argnum = 1 contact = await get_contact_from_arg(mc, cmds[1]) await interactive_loop(mc, to=contact) case "script" : if len(cmds) > 1: argnum = 1 file_name = cmds[1] else: file_name = await prompt_for_file() if not file_name is None: await process_script(mc, file_name, json_output=json_output) case _ : contact = await get_contact_from_arg(mc, cmds[0]) if contact is None: logger.error(f"Unknown command : {cmd}, {cmds} not executed ...") return None await interactive_loop(mc, to=contact) logger.debug(f"cmd {cmds[0:argnum+1]} processed ...") return cmds[argnum+1:] except IndexError: logger.error("Error in parameters") return None except (EOFError, KeyboardInterrupt): logger.error("Cancelled") return None async def process_cmds (mc, args, json_output=False) : cmds = args while cmds and len(cmds) > 0 and cmds[0][0] != '#' : cmds = await next_cmd(mc, cmds, json_output) async def process_script(mc, file, json_output=False): if not os.path.exists(file) : logger.info(f"file {file} not found") if json_output : print(json.dumps({"error" : f"file {file} not found"})) return with open(file, "r") as f : lines=f.readlines() for line in lines: line = line.strip() if not (line == "" or line[0] == "#"): logger.debug(f"processing {line}") try : cmds = shlex.split(line) await process_cmds(mc, cmds, json_output) except ValueError: logger.error(f"Error processing {line}") def version(): print (f"meshcore-cli: command line interface to MeshCore companion radios {VERSION}") def command_help(): print(""" ? may give you some more help about cmd General commands chat : enter the chat (interactive) mode chat_to : enter chat with contact to script : execute commands in filename infos : print informations about the node i self_telemetry : print own telemtry t card : export this node URI e ver : firmware version v reboot : reboots node sleep : sleeps for a given amount of secs s wait_key : wait until user presses wk apply_to : sends cmds to contacts matching f at Messaging msg : send message to node by name m { wait_ack : wait an ack wa } chan : send message to channel number ch public : send message to public channel (0) dch recv : reads next msg r wait_msg : wait for a message and read it wm sync_msgs : gets all unread msgs from the node sm msgs_subscribe : display msgs as they arrive ms get_channels : prints all channel info get_channel : get info for channel (by number or name) set_channel n nm [k] : set channel info (nb, name, key) add_channel name [key] : add new channel with optional key remove_channel : remove channel (by number or name) scope : sets scope for flood messages Management advert : sends advert a floodadv : flood advert get : gets a param, \"get help\" for more set : sets a param, \"set help\" for more time : sets time to given epoch clock : get current time clock sync : sync device clock st node_discover : discovers nodes based on their type nd Contacts contacts / list : gets contact list lc reload_contacts : force reloading all contacts rc contact_info : prints information for contact ct ci contact_timeout v : sets temp default timeout for contact share_contact : share a contact with others sc export_contact : get a contact's URI ec import_contact : import a contact from its URI ic remove_contact : removes a contact from this node path : diplays path for a contact disc_path : discover new path and display dp reset_path : resets path to a contact to flood rp change_path : change the path to a contact cp advert_path : get path from advert ap change_flags : change contact flags (tel_l|tel_a|star)cf req_acl : requests access control list for node ra req_telemetry : prints telemetry data as json rt req_regions : prints regions from repeater rr req_owner : prints owner for a repeater ro req_clock : prints repeater timestamp (for sync) req_mma : requests min/max/avg for a sensor rm pending_contacts : show pending contacts add_pending : manually add pending contact flush_pending : flush pending contact list Repeaters login : log into a node (rep) with given pwd l logout : log out of a repeater cmd : sends a command to a repeater (no ack) c [ wmt8 : wait for a msg (reply) with a timeout ] req_status : requests status from a node rs req_neighbours : requests for neighbours in binary form rn trace : run a trace, path is comma separated""") def usage () : """ Prints some help """ version() command_usage() print(" Available Commands and shorcuts (can be chained) :""") command_help() def command_usage() : print(""" Usage : meshcore-cli Arguments : -h : prints help for arguments and commands -v : prints version -j : json output (disables init file) -D : debug (sets logging to DEBUG) -q : quiet (sets logging to ERROR) -S : scan for devices and show a selector -l : list available ble/serial devices and exit -T : timeout for the ble scan (-S and -l) default 2s -a
: specifies device address (can be a name) -d : filter meshcore devices with name or address -P : forces pairing via the OS -t : connects via tcp/ip -p : specifies tcp port (default 5000) -s : use serial port -b : specify baudrate -C : toggles classic mode for prompt -c : disables most of color output if off -r : repeater mode (raw text CLI, use with -s) """) def get_help_for (cmdname, context="line") : if cmdname == "apply_to" or cmdname == "at" : print("""apply_to : applies cmd to contacts matching filter Filter is constructed with comma separated fields : - u, matches modification time < or > than a timestamp (can also be days hours or minutes ago if followed by d,h or m) - t, matches the type (1: client, 2: repeater, 3: room, 4: sensor) - h, matches number of hops - d, direct, similar to h>-1 - f, flood, similar to h<0 or h=-1 - b, show nodes that have all specified flag bits on (bit 0 is favourite) 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 ... Examples: # removes all clients that have not been updated in last 2 days at u<2d,t=1 remove_contact # gives traces to repeaters that have been updated in the last 24h and are direct at t=2,u>1d,d cn trace # tries to do flood login to all repeaters at t=2 rp login """) elif cmdname == "node_discover" or cmdname == "nd" : print("""node_discover : discovers 0-hop nodes and displays signal info filter can be "all" for all types or nodes or a comma separated list consisting of : - cli or comp for companions - rep for repeaters - sens for sensors - room for chat rooms nd can be used with no filter parameter ... !!! BEWARE WITH CHAINING !!! """) elif cmdname == "get" : print("""Gets parameters from node Please see also help for set command, which is more up to date ... name : node name bat : battery level in mV fstats : fs statistics coords : adv coordinates lat : latitude lon : longitude radio : radio parameters tx : tx power private_key : private key of the node print_snr : snr display in messages print_adverts : display adverts as they come print_new_contacts : display new pending contacts when available print_path_updates : display path updates as they come custom : all custom variables in json format each custom var can also be get/set directly stats/status : print status of the node stats_core : core stats (bat/error/uptime/queue) stats_radio : radio stats (noise/rssi/snr/tx_air/rx_air) stats_packets : packets stats (recv/sent/flood/direct) allowed_repeat_freq: possible frequency ranges for repeater mode path_hash_mode """) elif cmdname == "set" : print("""Available parameters : device: pin : ble pin radio : radio params tuning : tuning params tx : tx power name : node name lat : latitude lon : longitude private_key : private key coords : coordinates multi_ack : multi-acks feature telemetry_mode_base : set basic telemetry mode all/selected/off telemetry_mode_loc : set location telemetry mode all/selected/off telemetry_mode_env : set env telemetry mode all/selected/off advert_loc_policy : "share" means loc will be shared in adv manual_add_contacts : let user manually add contacts to device - when off device automatically adds contacts from adverts - when on contacts must be added manually using add_pending (pending contacts list is built by meshcli from adverts while connected) autoadd_config : set autoadd_config flags (see ?autoadd) path_hash_mode display: print_timestamp : toggle printing of timestamp, can be strftime format print_snr : toggle snr display in messages print_adverts : display adverts as they come print_new_contacts : display new pending contacts when available print_path_updates : display path updates as they come json_log_rx : logs packets incoming to device as json channel_echoes : print repeats for channel data advert_echoes : print repeats for adverts echo_unk_chans : also dump unk channels (encrypted) color : color off should remove ANSI codes from output meshcore-cli behaviour: classic_prompt : activates less fancier prompt arrow_head : change arrow head in prompt slash_start : idem for slash start slash_end : slash end invert_slash : apply color inversion to slash auto_update_contacts : auto sync contact list with device """) elif cmdname == "scope": print("""scope : changes flood scope of the node The scope command can be used from command line or interactive mode to set the region in which flood packets will be transmitted. Managing Flood Scope in interactive mode 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. When entering chat mode, scope will be reset to *, meaning classic flood. You can switch scope using the scope command, or postfixing the to command with %. 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. default_scope for the device can be set/get by using set default_scope and get default_scope, if set, the scope will revert to this default. """) elif cmdname == "contact_info": print("""contact_info : displays contact info in interactive mode, there are some lighter commands that can be chained to give more compact information - contact_name (cn) - contact_key (ck) - contact_type (ct) """) elif cmdname == "pending_contacts" or cmdname == "flush_pending" or cmdname == "add_pending" or cmdname == "autoadd": print("""Contact management 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). 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. 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). This feature only really works in interactive mode. You can also set autoadd_config flag to filter contacts that are automatically added to your contact list (this will work when manual_add_contact is set to True). // Auto-add config bitmask // Bit 0: If set, overwrite oldest non-favourite contact when contacts file is full // Bits 1-4: these indicate which contact types to auto-add when manual_contact_mode = 0x01 #define AUTO_ADD_OVERWRITE_OLDEST (1 << 0) // 0x01 - overwrite oldest non-favourite when full #define AUTO_ADD_CHAT (1 << 1) // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT) #define AUTO_ADD_REPEATER (1 << 2) // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER) #define AUTO_ADD_ROOM_SERVER (1 << 3) // 0x08 - auto-add Room Server (ADV_TYPE_ROOM) #define AUTO_ADD_SENSOR (1 << 4) // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR) Instead of an int you can use can make a string containing keys which can be (ov, cli, rep, room or sen), parser will look for keys in the string. 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). """) elif "channel" in cmdname: print("""Channel management Channels are used to send messages to a group of people. This group of people share a common key, used to encrypt, identify and decrypt the messages that are sent flood over the network (possibly with a scope). Channel commands are the following: - get_channels - get_channel chan - add_channel name [key] - set_channel chan name [key] - remove_channel chan There is a fixed number of slots on companions to store channel messages, each channel has a number, a name and a key, the get_channels command lists theses slots. You can also call get_channel (with number or name) to get information about one channel. Adding a channel can be done using the set_channel command, taking as parameters the channel number, the name and the key. Key is optional, if not provided, it will be computed from the name. The add_channel command won't take a number as it will use first available slot. There is a special case for auto channels, which starts with a #, these have always their key computed from the name (note that mccli does not lowercase and strip characters so you should be carefull when sharing when users of the android app or ripple). To remove a channel, use remove_channel, either with channel name or number. """) elif cmdname == "trace" or cmdname == "tr" : print("""Trace Trace is a command used to get signal information (SNR) along a path. Basic call to trace takes the path to follow as an argument, specifying each repeater along the path with its hash (separated or not with a comma). Example: Track-R|*> trace 6a61 โ†’13.25โ†’[6a]โ†’12.50โ†’[61]โ†’13.50โ†’ At the begining hashes were only 1 byte long. But with firmware after 1.12 you can use multi byte paths (2 bytes long and 4 bytes long hashes). The flag specifying the size of the hashes will either be guessed from the size of the tokens when used with commas, or specified using a colon (0: 1 byte, 1: 2 bytes, 3: 4 bytes), so AAAA,BBBB or AAAABBBB:1 are equivalent. When there is only one repeater on the path, you can put a comma at the end of the path to get the hash size right. Here are some examples : Track-R|*> trace 6a,61 โ†’13.25โ†’[6a]โ†’12.50โ†’[61]โ†’13.50โ†’ Track-R|*> trace 6a61:0 โ†’13.25โ†’[6a]โ†’12.50โ†’[61]โ†’13.50โ†’ Track-R|*> trace 6a83,6144 โ†’11.75โ†’[6a83]โ†’12.25โ†’[6144]โ†’13.00โ†’ Track-R|*> trace 6a836144:1 โ†’12.00โ†’[6a83]โ†’12.00โ†’[6144]โ†’13.75โ†’ Track-R|*> trace 6a83, โ†’13.25โ†’[6a83]โ†’13.50โ†’ Track-R|*> trace 6a83:1 โ†’12.75โ†’[6a83]โ†’12.50โ†’ Track-R|*> You can also send a trace with a node as parameter, it will (if path to that node is set) use the outgoing path for outgoing and incoming path. If destination is a repeater the trace will be done to the destination, or else to the last repeater of the path. Track-R/SDQ_FdL_Rep|6a83> trace โ†’12.25โ†’[6a83]โ†’12.00โ†’[6144]โ†’12.00โ†’[6a83]โ†’12.00โ†’ In this case, the repeater had a path configured with 2 bytes hash, so it did a two bytes trace, going to the repeater and then coming back. See also ?path """) elif "path" in cmdname : print("""path management (reset_path, change_path) In Meshcore, there are two ways for a packet to reach a destination, flood or path. Flood messages are send through the mesh and will be repeated once by each repeater along the way (building a path in the packet, so the destination knows where the packet came from). Path message have a path encoded in them, each repeater along the way will repeat the packet and remove its own hash from the path (once at the destination, path is empty). The path for each contact is stored in the contact information, along with the path len and the path_hash_mode (specifying if its hash is 1, 2 or 3 bytes long. 0 for 1, 1 for 2 and 2 for 3). A path len of 255 (or -1 if signed) means path is not set (flood). meshcore-cli provides some functions to manage path : * path : print path to a node * reset_path : set path back to flood * change_path : specify path to destination * contact_info : get all information for a contact * advert_path : path taken by an advert * disc_path : discover in and out path for a contact When using change_path, you specify manually the path to the contact. Path is given as an hex string containing hashes for all repeaters in the way (you can use commas to separate hashes). By default hash_size will be the one of the node. If using commas, it will be guessed from first hash. You can also use a colon to specify path_hash_mode. If you want to set the path for a node through 112233 445566 778899, you can use - 114477:0 or 11,44,77 for one byte hash - 112244557788:1 or 1122,4455,7788 for two byte hash - 112233445566778899:2 or 112233,445566,778899 for three byte hash To set an empty path use 0. To get the path for a contact, you can use three commands: - path will gives you the path stored in the node. - You can also get a path from a key using advert_path which will give you the path taken for last advert from that node to come. disc_path will send a path request and give you input and output path for a node. Note that the path shown on the prompt only uses 1 byte notation without commas to keep it slim. """) else: print(f"Sorry, no help yet for {cmdname}") # Repeater mode history file MCCLI_REPEATER_HISTORY_FILE = MCCLI_CONFIG_DIR + "repeater_history" MCCLI_REGION_FILES_HISTORY = MCCLI_CONFIG_DIR + "region_files_history" # Repeater command completion dictionary REPEATER_COMMANDS = { "ver": None, "board": None, "reboot": None, "advert": None, "clock": {"sync": None}, "time": None, "neighbors": None, "neighbor.remove": None, "discover.neighbors": None, "stats-core": None, "stats-radio": None, "stats-packets": None, "clear": {"stats": None}, "log": {"start": None, "stop": None, "erase": None}, "get": { "name": None, "radio": None, "tx": None, "freq": None, "public.key": None, "prv.key": None, "repeat": None, "role": None, "lat": None, "lon": None, "af": None, "rxdelay": None, "txdelay": None, "direct.txdelay": None, "flood.max": None, "flood.advert.interval": None, "advert.interval": None, "guest.password": None, "allow.read.only": None, "multi.acks": None, "int.thresh": None, "agc.reset.interval": None, "bridge.enabled": None, "bridge.delay": None, "bridge.source": None, "bridge.baud": None, "bridge.channel": None, "bridge.secret": None, "bridge.type": None, "adc.multiplier": None, "acl": None, "owner.info": None, }, "set": { "name": None, "radio": None, "tx": None, "freq": None, "prv.key": None, "repeat": {"on": None, "off": None}, "lat": None, "lon": None, "af": None, "rxdelay": None, "txdelay": None, "direct.txdelay": None, "flood.max": None, "flood.advert.interval": None, "advert.interval": None, "guest.password": None, "allow.read.only": {"on": None, "off": None}, "multi.acks": None, "int.thresh": None, "agc.reset.interval": None, "bridge.enabled": {"on": None, "off": None}, "bridge.delay": None, "bridge.source": None, "bridge.baud": None, "bridge.channel": None, "bridge.secret": None, "adc.multiplier": None, "owner.info": None, }, "powersaving": {"on":None, "off":None,}, "password": None, "erase": None, "gps": {"on": None, "off": None, "sync": None, "setloc": None, "advert": {"none": None, "share": None, "prefs": None}}, "sensor": {"list": None, "get": None, "set": None}, "region": {"get": None, "put": None, "remove": None, "save": None, "load": None, "home": None, "allowf": None, "denyf": None, "upload": None, "download": None}, "setperm": None, "tempradio": None, "quit": None, "q": None, "help": None, } REPEATER_HELP = f""" {ANSI_BCYAN}Repeater CLI Commands:{ANSI_END} {ANSI_BGREEN}Info:{ANSI_END} ver - Firmware version board - Board name clock - Show current time {ANSI_BGREEN}Stats:{ANSI_END} stats-core - Core stats (uptime, battery, queue) stats-radio - Radio stats (RSSI, SNR, noise floor) stats-packets - Packet statistics (sent/recv counts) clear stats - Reset all statistics {ANSI_BGREEN}Network:{ANSI_END} neighbors - Show neighboring repeaters (zero-hop) advert - Send advertisement now {ANSI_BGREEN}Logging:{ANSI_END} log start - Enable packet logging log stop - Disable packet logging log - Dump log file to console log erase - Erase log file {ANSI_BGREEN}Configuration (get/set):{ANSI_END} get name - Node name get radio - Radio params (freq,bw,sf,cr) get tx - TX power (dBm) get repeat - Repeat mode on/off get public.key - Node public key get advert.interval - Advertisement interval (minutes) get owner.info - Owner information set name - Set node name set tx - Set TX power (dBm) set repeat on|off - Enable/disable repeating set radio f,bw,sf,cr - Set radio params (reboot to apply) set advert.interval - Set advert interval (60-240 min) set owner.info - Set owner information {ANSI_BGREEN}Region management:{ANSI_END} region - display currently configured regions region save - save current region config to flash region download - download regions config from node to file region (up)load - upload regions config to node from file region home - get/set home region region get - get info (and parent) for a region region put - adds or update a region region remove - remove a region definition region allowf - gives flood permission to a region region denyf - remove flood permission to a region region list - list allowed/denied regions {ANSI_BGREEN}System:{ANSI_END} reboot - Reboot device erase - Erase filesystem (serial only) script - Execute script {ANSI_BYELLOW}Type 'quit' or 'q' to exit, Ctrl+C to abort{ANSI_END} """ async def prompt_for_file(): try: if os.path.isDIR(MCCLI_CONFIG_DIR): region_files_history = FileHistory(MCCLI_REGION_FILES_HISTORY) else: region_files_history = None except Exception: region_files_history = None file_session = PromptSession( history=region_files_history, wrap_lines=False, mouse_support=False, complete_style=CompleteStyle.MULTI_COLUMN ) # Setup key bindings bindings = KeyBindings() @bindings.add("escape") def _(event): event.app.current_buffer.cancel_completion() path_completer = PathCompleter(expanduser=True) file_path = await file_session.prompt_async( "Enter filename (Tab to complete CTRL+C to cancel): ", completer=path_completer, complete_while_typing=False, key_bindings=bindings ) return file_path async def process_repeater_script(ser, file): if not os.path.exists(file) : logger.info(f"file {file} not found") return with open(file, "r") as f : lines=f.readlines() for line in lines: line = line.strip() if not (line == "" or line[0] == "#"): logger.debug(f"processing {line}") try : res = await process_repeater_line(ser, line, echo=True) if not res: logger.info("Error during script execution, exiting") break except ValueError: logger.error(f"Error processing {line}") break async def process_repeater_line(ser, cmd, echo=False, repeater_name=None) : if cmd.lower() == "help": print(REPEATER_HELP) return True if cmd.lower() == "clock sync" or cmd.lower() == "st" or cmd.lower() == "sync_time": cur_time = int(time.time()) print(f'{ANSI_GREEN}Syncing clock to' f' {datetime.datetime.fromtimestamp(cur_time).strftime("%Y-%m-%d %H:%M:%S")}' f' ({cur_time}){ANSI_END}') cmd = f"time {cur_time}" if cmd.lower().startswith("region upload") or\ cmd.lower().startswith("region load"): # removing normal load behavior try: if len(cmd.split(" ")) < 3: # prompt for a filename file_path = await prompt_for_file() else : file_path = cmd.lower().split(" ", 3)[2] file_path = file_path.replace("~", str(Path.home())) with open(file_path, "r") as file: ser.write("region load\r".encode()) for line in file: if line.strip() == "": # terminate on empty line break if not line.startswith(";"): # don't send lines starting with ; ser.write(f"{line.rstrip()}\r".encode()) ser.write("\r".encode()) except FileNotFoundError: logger.error("File not found") return False except (EOFError, KeyboardInterrupt): logger.info("Region upload canceled") return False # in any case, send an empty line and clean buffer cmd = "" if cmd.lower().startswith("region download"): try: if cmd.lower() == "region download": # prompt for a filename file_path = await prompt_for_file() else : file_path = cmd.lower().split(" ", 3)[2] file_path = file_path.replace("~", str(Path.home())) with open(file_path, "w") as file: # write header (name and timestamp) if repeater_name is None: repeater_name = await get_repeater_name(ser) file.write(f"; Regions spec downloaded from {repeater_name}\n") file.write(f"; On {datetime.datetime.fromtimestamp(time.time()).strftime('%a %d %b %Y %H:%M %z')}\n") ser.write("region\r".encode()) # send regions command # seek start of regions description line = ser.readline().decode(errors='ignore') while not line.startswith(" ->") : line = ser.readline().decode(errors='ignore') line = line[5:] while line.rstrip() != "": file.write(line) line = ser.readline().decode(errors='ignore') logger.info(f"{ANSI_CYAN}Wrote regions spec to {file_path}{ANSI_END}") except FileNotFoundError: logger.error("File not found") return False except (EOFError, KeyboardInterrupt): logger.info("Region download canceled") return False return True if cmd.lower().startswith("script"): try: if cmd.lower() == "script": # prompt for a filename file_path = await prompt_for_file() else : file_path = cmd.lower().split(" ", 2)[1] file_path = file_path.replace("~", str(Path.home())) return await process_repeater_script(ser, file_path) except FileNotFoundError: logger.error("File not found") return False except (EOFError, KeyboardInterrupt): logger.info("Script canceled") return False # Send command with CR terminator if cmd != "": ser.write(f"{cmd}\r".encode()) await asyncio.sleep(0.3) # Read response result = True response = ser.read(ser.in_waiting or 4096).decode(errors='ignore') if response: # Clean up echo and format response lines = response.rstrip().split('\n') for line in lines: line = line.rstrip() if line and (echo or line != cmd): # Skip echo of command # Color code certain responses if line.strip().startswith("OK") or line.strip().startswith("ok"): print(f"{ANSI_GREEN}{line}{ANSI_END}") elif line.strip().startswith("Error") or line.strip().startswith("ERR"): print(f"{ANSI_RED}{line}{ANSI_END}") elif line.strip().startswith("->"): print(f"{ANSI_CYAN}{line}{ANSI_END}") elif line.strip().startswith("DEBUG"): pass else: print(line) if "-> Unknown command" in line or \ "-> Error" in line : result = False return result async def setup_repeater_serial(port, baudrate): """Interactive loop for repeater text CLI (raw serial commands)""" import serial as pyserial logger.info(f"{ANSI_BCYAN}Connecting to repeater at {port} ({baudrate} baud)...{ANSI_END}") try: ser = pyserial.Serial(port, baudrate, timeout=1) except PermissionError: print(f"{ANSI_BRED}Error: Permission denied. Try running with sudo or add user to dialout group.{ANSI_END}") return None except Exception as e: print(f"{ANSI_BRED}Error opening serial port: {e}{ANSI_END}") return None await asyncio.sleep(0.5) # Wait for connection to stabilize ser.reset_input_buffer() # Send initial CR to wake up CLI ser.write(b"\r") await asyncio.sleep(0.2) ser.reset_input_buffer() return ser async def get_repeater_name(ser): # Try to get device name ser.write(b"get name\r") line = "" while not line.startswith("get name"): line = ser.readline().decode(errors="ignore").rstrip() device_name = "Repeater" line = ser.readline().decode(errors="ignore").rstrip() if (line.startswith(" -> > ")): device_name = line[7:] return device_name async def get_repeater_version(ser): # Getting version ser.write(b"ver\r") line = ser.readline().decode(errors="ignore").rstrip() while not line == "ver": line = ser.readline().decode(errors="ignore").rstrip() device_version = "unknown" line = ser.readline().decode(errors="ignore").rstrip() if (line.startswith(" -> ")): device_version = line[6:] return device_version async def repeater_loop(ser): device_name = await get_repeater_name(ser) device_version = await get_repeater_version(ser) print(f"{ANSI_BGREEN}Connected!{ANSI_END} Device: {ANSI_BMAGENTA}{device_name}{ANSI_END} version {ANSI_BMAGENTA}{device_version}{ANSI_END}") print(f"Type {ANSI_BCYAN}help{ANSI_END} for commands, {ANSI_BCYAN}quit{ANSI_END} to exit, {ANSI_BCYAN}Tab{ANSI_END} for completion") print("-" * 50) # Setup history and session try: if os.path.isdir(MCCLI_CONFIG_DIR): our_history = FileHistory(MCCLI_REPEATER_HISTORY_FILE) else: our_history = None except Exception: our_history = None session = PromptSession( history=our_history, wrap_lines=False, mouse_support=False, complete_style=CompleteStyle.MULTI_COLUMN ) # Setup key bindings bindings = KeyBindings() @bindings.add("escape") def _(event): event.app.current_buffer.cancel_completion() # Build prompt prompt_base = f"{ANSI_BGRAY}{device_name}{ANSI_MAGENTA}>{ANSI_END} " # Setup completer completer = NestedCompleter.from_nested_dict(REPEATER_COMMANDS) while True: try: cmd = await session.prompt_async( ANSI(prompt_base), completer=completer, complete_while_typing=False, key_bindings=bindings ) except (KeyboardInterrupt, EOFError): break cmd = cmd.strip() if not cmd: continue if cmd.lower() in ("quit", "exit", "q"): break await process_repeater_line(ser, cmd, repeater_name=device_name) async def main(argv): """ Do the job """ json_output = JSON debug = False address = ADDRESS device = None port = 5000 hostname = None serial_port = None baudrate = 115200 repeater_mode = False timeout = 2 pin = None first_device = False quiet = False # If there is an address in config file, use it by default # unless an arg is explicitely given if os.path.exists(MCCLI_ADDRESS) : with open(MCCLI_ADDRESS, encoding="utf-8") as f : address = f.readline().strip() try: opts, args = getopt.getopt(argv, "a:d:s:ht:p:b:fjDhvSlT:Pc:Crq") except getopt.GetoptError: print("Unrecognized option, use -h to get more help") command_usage() return for opt, arg in opts : match opt: case "-c" : if arg == "off": process_event_message.color = False case "-C": interactive_loop.classic = not interactive_loop.classic case "-r": # repeater mode (raw text CLI) repeater_mode = True case "-d" : # name specified on cmdline address = arg case "-a" : # address specified on cmdline address = arg case "-P" : # pairing pin = True case "-s" : # serial port serial_port = arg case "-b" : baudrate = int(arg) case "-t" : hostname = arg case "-p" : port = int(arg) case "-j" : json_output=True handle_message.json_output=True case "-D" : debug=True case "-h" : usage() return case "-T" : timeout = float(arg) case "-v": version() return case "-f": # connect to first encountered device first_device = True case "-q": # quiet (turns logger to ERROR only) quiet = True case "-l" : if BLEAK_AVAILABLE and not repeater_mode: print("BLE devices:") try : devices = await BleakScanner.discover(timeout=timeout) if len(devices) == 0: print(" No ble device found") for d in devices : if not d.name is None and d.name.startswith("MeshCore-"): print(f" {d.address} {d.name}") except (BleakError, BleakDBusError): print(" No BLE HW") print("") print("Serial ports:") ports = serial.tools.list_ports.comports() for port, desc, hwid in sorted(ports): print(f" {port:<18} {desc} [{hwid}]") return case "-S" : choices = [] if BLEAK_AVAILABLE and not repeater_mode: try : devices = await BleakScanner.discover(timeout=timeout) for d in devices: if not d.name is None and d.name.startswith("MeshCore-"): choices.append(({"type":"ble","device":d}, f"{d.address:<22} {d.name}")) except (BleakError, BleakDBusError): logger.info("No BLE Device") ports = serial.tools.list_ports.comports() for port, desc, hwid in sorted(ports): choices.append(({"type":"serial","port":port}, f"{port:<22} {desc}")) if len(choices) == 0: logger.error("No device found, exiting") return result = await radiolist_dialog( title="MeshCore-cli device selector", text="Choose the device to connect to :", values=choices ).run_async() if result is None: logger.info("No choice made, exiting") return if result["type"] == "ble": device = result["device"] elif result["type"] == "serial": serial_port = result["port"] else: logger.error("Invalid choice") return if (debug==True): logger.setLevel(logging.DEBUG) elif (json_output or quiet) : logger.setLevel(logging.ERROR) # Repeater mode - raw text CLI over serial if repeater_mode: if serial_port is None: logger.error("Repeater mode (-r) requires serial port (-s)") command_usage() return ser = await setup_repeater_serial(serial_port, baudrate) logger.debug(f"Serial port opened: {ser}") if (len(args) > 0) : await process_repeater_line(ser, " ".join(args)) else: await repeater_loop(ser) ser.close() logger.info(f"{ANSI_BGRAY}Disconnected from repeater.{ANSI_END}") return mc = None if not hostname is None : # connect via tcp mc = await MeshCore.create_tcp(host=hostname, port=port, debug=debug, only_error=json_output) elif not serial_port is None : # connect via serial port mc = await MeshCore.create_serial(port=serial_port, baudrate=baudrate, debug=debug, only_error=json_output) if mc is None: # did not connect logger.error("To connect to a repeater, use -r option.") elif BLEAK_AVAILABLE : # connect via ble client = None if device or address and len(address.split(":")) == 6 : pass elif address and len(address) == 36 and len(address.split("-")) == 5: client = BleakClient(address) # mac uses uuid, we'll pass a client else: if address == "": logger.info(f"Searching first MC BLE device") else: logger.info(f"Scanning BLE for device matching {address}") try: devices = await BleakScanner.discover(timeout=timeout) except (BleakError, BleakDBusError): print("BLE connection asked (default behaviour), but no BLE HW found") print("Call meshcore-cli with -h for some more help (on commands)") command_usage() return found = False for d in devices: if not d.name is None and d.name.startswith("MeshCore-") and\ (address is None or address in d.name) : address=d.address device=d logger.info(f"Found device {d.name} {d.address}") found = True break elif d.address == address : # on a mac, address is an uuid device = d logger.info(f"Found device {d.name} {d.address}") found = True break if not found : logger.info(f"Couldn't find device {address}") return try : mc = await MeshCore.create_ble(address=address, device=device, client=client, debug=debug, only_error=json_output, pin=pin) except (BleakError, BleakDBusError): print("BLE connection asked (default behaviour), but no BLE HW found") print("Call meshcore-cli with -h for some more help (on commands)") command_usage() return except ConnectionError : logger.info("Error while connecting, retrying once ...") if first_device : address = "" # reset address to change device if first_device was asked if device is None and client is None: # Search for device logger.info(f"Scanning BLE for device matching {address}") devices = await BleakScanner.discover(timeout=timeout) found = False for d in devices: if not d.name is None and d.name.startswith("MeshCore-") and\ (address is None or address in d.name) : address=d.address device=d logger.info(f"Found device {d.name} {d.address}") found = True break elif d.address == address : # on a mac, address is an uuid device = d logger.info(f"Found device {d.name} {d.address}") found = True break if not found : logger.info(f"Couldn't find device {address}") return try : mc = await MeshCore.create_ble(address=address, device=device, client=client, debug=debug, only_error=json_output, pin=pin) except ConnectionError : logger.error("Can't connect to node, exiting") return # Store device address in configuration if os.path.isdir(MCCLI_CONFIG_DIR) : with open(MCCLI_ADDRESS, "w", encoding="utf-8") as f : if not device is None: f.write(device.address) elif not address is None: f.write(address) if mc is None: return handle_message.mc = mc # connect meshcore to handle_message handle_advert.mc = mc handle_path_update.mc = mc handle_log_rx.mc = mc mc.subscribe(EventType.ADVERTISEMENT, handle_advert) mc.subscribe(EventType.PATH_UPDATE, handle_path_update) mc.subscribe(EventType.NEW_CONTACT, handle_new_contact) mc.subscribe(EventType.RX_LOG_DATA, handle_log_rx) mc.auto_update_contacts = True mc.set_decrypt_channel_logs(True) res = await mc.commands.send_device_query() if res.type == EventType.ERROR : logger.error(f"Error while querying device: {res}") return if os.path.isdir(MCCLI_CONFIG_DIR) : log_message.file = MCCLI_CONFIG_DIR + mc.self_info["name"] + ".msgs" if (json_output or quiet) : logger.setLevel(logging.ERROR) else : if res.payload["fw ver"] > 2 : logger.info(f"Connected to {mc.self_info['name']} running on a {res.payload['ver']} fw.") else : logger.info(f"Connected to {mc.self_info['name']}.") if os.path.exists(MCCLI_INIT_SCRIPT) and not json_output : logger.debug(f"Executing init script : {MCCLI_INIT_SCRIPT}") await process_script(mc, MCCLI_INIT_SCRIPT, json_output) device_init_script = MCCLI_CONFIG_DIR + mc.self_info["name"] + ".init" if os.path.exists(device_init_script) : logger.info(f"Executing device init script : {device_init_script}") await process_script(mc, device_init_script, json_output) else: logger.debug(f"No device init script for {mc.self_info['name']}") if len(args) == 0 : # no args, run in chat mode await process_cmds(mc, ["chat"], json_output) else: await process_cmds(mc, args, json_output) def cli(): try: asyncio.run(main(sys.argv[1:])) except KeyboardInterrupt: # This prevents the KeyboardInterrupt traceback from being shown print("\nExited cleanly") except Exception as e: print(f"Error: {e}") traceback.print_exc() if __name__ == '__main__': cli() meshcore_cli-1.5.7/pyproject.toml0000644000175000017500000000152013615410400016430 0ustar winniewinnie[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "meshcore-cli" version = "1.5.7" authors = [ { name="Florent de Lamotte", email="florent@frizoncorrea.fr" }, ] description = "Command line interface to meshcore companion radios and repeaters" readme = "README.md" requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent", ] license = "MIT" license-files = ["LICEN[CS]E*"] dependencies = [ "meshcore >= 2.3.7", "bleak >= 0.22", "prompt_toolkit >= 3.0.50", "requests >= 2.28.0" ] [project.urls] Homepage = "https://github.com/fdlamotte/meshcore-cli" Issues = "https://github.com/fdlamotte/meshcore-cli/issues" [project.scripts] meshcli = "meshcore_cli.meshcore_cli:cli" meshcore-cli = "meshcore_cli.meshcore_cli:cli" meshcore_cli-1.5.7/LICENSE0000644000175000017500000000205213615410400014522 0ustar winniewinnieMIT License Copyright (c) 2025 fdlamotte Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. meshcore_cli-1.5.7/PKG-INFO0000644000175000017500000004366213615410400014626 0ustar winniewinnieMetadata-Version: 2.4 Name: meshcore-cli Version: 1.5.7 Summary: Command line interface to meshcore companion radios and repeaters Project-URL: Homepage, https://github.com/fdlamotte/meshcore-cli Project-URL: Issues, https://github.com/fdlamotte/meshcore-cli/issues Author-email: Florent de Lamotte License-Expression: MIT License-File: LICENSE Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Requires-Python: >=3.10 Requires-Dist: bleak>=0.22 Requires-Dist: meshcore>=2.3.7 Requires-Dist: prompt-toolkit>=3.0.50 Requires-Dist: requests>=2.28.0 Description-Content-Type: text/markdown # meshcore-cli meshcore-cli : CLI interface to MeshCore companion app over BLE, TCP or Serial ## About 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. You can send commands as parameters to the meshcore-cli command (from your shell) either interactively or through a script. 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). By default meshcore-cli interacts with companion radios (through BLE, Serial or TCP). A serial repeater mode has been introduced, which is triggered using `-r`, which mainly adds line editing, history and completion to the serial interface, adds time syncing through `clock sync` and let you download/upload regions specs from a file. 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. ## Install 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:
$ pipx install meshcore-cli
It will install you `meshcore-cli` and `meshcli`, which is an alias to the former. You can use the flake under [nix](https://nixos.org/):
$ nix run github:meshcore-dev/meshcore-cli#meshcore-cli
If you want meshcore-cli to remember last BLE device, you should have some `$HOME/.config/meshcore` where configuration for meschcore-cli will be stored (if not it will use first device it finds). ## Usage
$ meshcli <args> <commands>
If using BLE, don't forget to pair your device first (using `bluetoothctl` for instance on Linux) or meshcli won't be able to communicate. There is a device selector for BLE, you'll just have to use `meshcli -S` to select your device, subsequent calls to meshcli will be send to that device. ### Configuration Configuration files are stored in `$HOME/.config/meshcore` If the directory exists, default BLE address and history will be stored there. If there is an initialization script file called `init`, it will be executed just before the commands provided on command line are executed (and after evaluation of the arguments). Init files can also be defined for a given device, meshcore-cli will look for `.init` file in configuration directory (useful to specify timeout for contacts that are behind bridges with `contact_timeout` command). ### Arguments Arguments mostly deals with connection to the node
    -h : prints this help
    -v : prints version
    -j : json output (disables init file)
    -D : debug
    -S : scan for devices and show a selector
    -l : list available ble/serial devices and exit
    -T <timeout>    : timeout for the ble scan (-S and -l) default 2s
    -a <address>    : specifies device address (can be a name)
    -d <name>       : filter meshcore devices with name or address
    -P              : forces pairing via the OS
    -t <hostname>   : connects via tcp/ip
    -p <port>       : specifies tcp port (default 5000)
    -s <port>       : use serial port <port>
    -b <baudrate>   : specify baudrate
    -C              : toggles classic mode for prompt
    -c <on/off>     : disables most of color output if off
### Available Commands Commands are given after arguments, they can be chained and some have shortcuts. Also prefixing a command with a dot `.` will force it to output json instead of synthetic result.
    ?<cmd> may give you some more help about cmd
  General commands
    chat                   : enter the chat (interactive) mode
    chat_to <ct>           : enter chat with contact                to
    script <filename>      : execute commands in filename
    infos                  : print informations about the node      i
    self_telemetry         : print own telemtry                     t
    card                   : export this node URI                   e
    ver                    : firmware version                       v
    reboot                 : reboots node
    sleep <secs>           : sleeps for a given amount of secs      s
    wait_key               : wait until user presses <Enter>        wk
    apply_to <f> <cmds>    : sends cmds to contacts matching f      at
  Messaging
    msg <name> <msg>       : send message to node by name           m  {
    wait_ack               : wait an ack                            wa }
    chan <nb> <msg>        : send message to channel number <nb>    ch
    public <msg>           : send message to public channel (0)     dch
    recv                   : reads next msg                         r
    wait_msg               : wait for a message and read it         wm
    sync_msgs              : gets all unread msgs from the node     sm
    msgs_subscribe         : display msgs as they arrive            ms
    get_channels           : prints all channel info
    get_channel <n>        : get info for channel (by number or name)
    set_channel n nm k     : set channel info (nb, name, key)
    remove_channel <n>     : remove channel (by number or name)
    scope <s>              : sets node's flood scope
  Management
    advert                 : sends advert                           a
    floodadv               : flood advert
    get <param>            : gets a param, \"get help\" for more
    set <param> <value>    : sets a param, \"set help\" for more
    time <epoch>           : sets time to given epoch
    clock                  : get current time
    clock sync             : sync device clock                      st
    node_discover <filter> : discovers nodes based on their type    nd
  Contacts
    contacts / list        : gets contact list                      lc
    reload_contacts        : force reloading all contacts           rc
    contact_info <ct>      : prints information for contact ct      ci
    contact_timeout <ct> v : sets temp default timeout for contact
    share_contact <ct>     : share a contact with others            sc
    export_contact <ct>    : get a contact's URI                    ec
    import_contact <URI>   : import a contact from its URI          ic
    remove_contact <ct>    : removes a contact from this node
    path <ct>              : diplays path for a contact
    disc_path <ct>         : discover new path and display          dp
    reset_path <ct>        : resets path to a contact to flood      rp
    change_path <ct> <pth> : change the path to a contact           cp
    change_flags <ct> <f>  : change contact flags (tel_l|tel_a|star)cf
    req_telemetry <ct>     : prints telemetry data as json          rt
    req_mma <ct>           : requests min/max/avg for a sensor      rm
    req_acl <ct>           : requests access control list for sensor
    pending_contacts       : show pending contacts
    add_pending <pending>  : manually add pending contact
    flush_pending          : flush pending contact list
  Repeaters
    login <name> <pwd>     : log into a node (rep) with given pwd   l
    logout <name>          : log out of a repeater
    cmd <name> <cmd>       : sends a command to a repeater (no ack) c  [
    wmt8                   : wait for a msg (reply) with a timeout     ]
    req_status <name>      : requests status from a node            rs
    req_neighbours <name>  : requests for neighbours in binary form rn
    trace <path>           : run a trace, path is comma separated
### Interactive Mode aka Instant Message or chat mode ... Chat mode lets you interactively interact with your node or remote nodes. It is automatically triggered when no option is given on the command line. You'll get a prompt with the name of your node. From here you can type meshcore-cli commands. The prompt has history and a basic completion (pressing tab will display possible command or argument options). The `to` command is specific to chat mode, it lets you enter the recipient for next command. By default you're on your node but you can enter other nodes or public rooms. Here are some examples : - `to ` : will enter dest (node or channel) - `to /`, `to ~` : will go to the root (your node) - `to ..` : will go to the last node (it will switch between the two last nodes, this is just a 1-depth history) - `to !` : will switch to the node you received last message from When you are in a node, the behaviour will depend on the node type, if you're on a chat node, it will send messages by default and you can chat. On a repeater or a room server, it will send commands (autocompletion has been set to comply with the CommonCli class of meshcore). To send a message through a room you'll have to prefix the message with a quote or use the send command. The `/` character is used to bypass the node you have currently selected using `to`: - `/` issues cmd command on the root - `//` will send cmd to selected node - `/ ` will send msg to dest (channel or node) #### Flood Scope in interactive mode 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. When entering chat mode, scope will be reset to `*`, meaning classic flood. You can switch scope using the `scope` command, or postfixing the `to` command with `%`. 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. #### Channel echoes It's sometimes interesting to know the path taken by a message received from a channel or which repeaters have repeated a sent message. The app give you the information by listening `rx_log` from the device, when obtained the information is attached to the message and can be read. In meshcore-cli I went lower-level by implementing channel echoes. When activated (with `/set channel_echoes on`), all the channel messages will be printed on the terminal along with the SNR and path taken. When sending a message, you'll have all the repeats from 0-hop repeaters as echoes, and when a message is received, you should see information about the received message, but also all the instances of the same message that might have reached you from another path. In the example below, a msg has been sent between two repeaters, 21 and 25. 25 repeated the message and 21 the repeat and both echoes came back to the node with different SNRs. ``` f1down/#fdl|*> 8 #fdl f1down: 8 [25] -4.75-112 #fdl f1down: 8 [2521] 1.00-109 ``` ### Contact management 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). 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. 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). This feature only really works in interactive mode. 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). ### Issuing batch commands to contacts with apply to `apply_to ` : applies cmd to contacts matching filter `` it can be used to apply the same command to a pool of repeaters, or remove some contacts matching a condition. Filter is constructed with comma separated fields : - `u`, matches modification time `<` or `>` than a timestamp (can also be days hours or minutes ago if followed by `d`,`h` or `m`) - `t`, matches the type (1: client, 2: repeater, 3: room, 4: sensor) - `h`, matches number of hops - `d`, direct, similar to `h>-1` - `f`, flood, similar to `h<0` or `h=-1` Commands should be written as if in interactive mode, if writing from the commandline don't forget to use commas to clearly delimit fields. Note: Some commands like `contact_name` (aka `cn`), `reset_path` (aka `rp`), `forget_password` (aka `fp`) can be chained. There is also a `sleep` command taking an optional time parameter. The sleep will be issued after the command, it helps limiting rate through repeaters ... #### Examples ``` # removes all clients that have not been updated in last 2 days at u<2d,t=1 remove_contact # gives traces to repeaters that have been updated in the last 24h and are direct at t=2,u>1d,d cn trace # tries to do flood login to all repeaters at t=2 rp login ``` ## Examples
# gets info from first ble MC device it finds (was -s but now used for serial port)
$ meshcore-cli -d "" infos
INFO:meshcore:Scanning for devices
INFO:meshcore:Found device : C2:2B:A1:D5:3E:B6: MeshCore-t114_fdl
INFO:meshcore:BLE Connection started
{
    "adv_type": 1,
    "tx_power": 22,
    "max_tx_power": 22,
    "public_key": "993acd42fc779962c68c627829b32b111fa27a67d86b75c17460ff48c3102db4",
    "adv_lat": 47.794,
    "adv_lon": -3.428,
    "radio_freq": 869.525,
    "radio_bw": 250.0,
    "radio_sf": 11,
    "radio_cr": 5,
    "name": "t114_fdl"
}

# getting time
$ meshcli -a C2:2B:A1:D5:3E:B6 clock
INFO:meshcore:BLE Connection started
Current time : 2025-04-18 08:19:26 (1744957166)

# If you're familiar with meshcli, you should have noted that 
# now output is not json only, to get json output, use -j 
# or prefix your commands with a dot
$ meshcli -a C2:2B:A1:D5:3E:B6 .clock
INFO:meshcore:BLE Connection started
{
    "time": 1744957249
}

# Using -j, meshcli will return replies in json format ...
$ meshcli -j -a C2:2B:A1:D5:3E:B6 clock
{
    "time": 1744957261
}

# So if I reboot the node, and want to set time, I can chain the commands
# and get that kind of output (even better by feeding it to jq)
$ meshcli reboot
INFO:meshcore:BLE Connection started
$ meshcli -j clock clock sync clock | jq -c
{ "time": 1715770371 }
{ "ok": "time synced" }
{ "time": 1745996105 }

# Now check if time is ok with human output (I don't read epoch time yet)
$ meshcli clock
INFO:meshcore:BLE Connection started
Current time : 2025-04-30 08:56:27 (1745996187)

# Now you'll probably want to send some messages ... 
# For that, there is the msg command, wait_ack
$ meshcli msg Techo_fdl "Hello T-Echo" wa
INFO:meshcore:BLE Connection started
Msg acked

# I can check the message on the techo
$ meshcli -d Techo sm
INFO:meshcore:Scanning for devices
INFO:meshcore:Found device : DE:B6:D0:68:D5:62: MeshCore-Techo_fdl
INFO:meshcore:BLE Connection started
t114_fdl(0): Hello T-Echo

# And reply using json output for more verbosity
# here I've used jq with -cs to get a compact array
$ meshcli msg t114_fdl hello wa | jq -cs
[{"type":0,"expected_ack":"4802ed93","suggested_timeout":2970},{"code":"4802ed93"}]

# But this could have been done interactively using the chat mode
# Here from the techo side. Note that un-acked messages will be
# signaled with an ! at the start of the prompt (or red color in color mode)
$ meshcli chat
INFO:meshcore:BLE Connection started
Interactive mode, most commands from terminal chat should work.
Use "to" to selects contact, "list" to list contacts, "send" to send a message ...
Line starting with "$" or "." will issue a meshcli command.
"quit" or "q" will end interactive mode
t114_fdl(D): Hello T-Echo
EnsibsRoom> Hi
!EnsibsRoom> to t114_fdl
t114_fdl> Hi
t114_fdl(D): It took you long to reply ...
t114_fdl> I forgot to set the recipient with the to command
t114_fdl(D): It happens ...
t114_fdl> 

# Loging into repeaters and sending commands is also possible
# directly from the chat, because we can use meshcli commands ;)
$ meshcli chat (pending msgs are shown at connexion ...)
INFO:meshcore:BLE Connection started
Interactive mode, most commands from terminal chat should work.
Use "to" to selects contact, "list" to list contacts, "send" to send a message ...
Line starting with "$" or "." will issue a meshcli command.
"quit" or "q" will end interactive mode
Techo_fdl(0): Cool to receive some msgs from you
Techo_fdl(D): Hi
Techo_fdl(D): I forgot to set the recipient with the to command
FdlRoom> login password
Login success
FdlRoom> clock
FdlRoom(0): 06:40 - 18/4/2025 UTC
FdlRoom>
meshcore_cli-1.5.7/flake.lock0000644000175000017500000000273113615410400015455 0ustar winniewinnie{ "nodes": { "flake-utils": { "inputs": { "systems": "systems" }, "locked": { "lastModified": 1731533236, "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "nixpkgs": { "locked": { "lastModified": 1759733170, "narHash": "sha256-TXnlsVb5Z8HXZ6mZoeOAIwxmvGHp1g4Dw89eLvIwKVI=", "owner": "NixOS", "repo": "nixpkgs", "rev": "8913c168d1c56dc49a7718685968f38752171c3b", "type": "github" }, "original": { "owner": "NixOS", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } }, "systems": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { "owner": "nix-systems", "repo": "default", "type": "github" } } }, "root": "root", "version": 7 } meshcore_cli-1.5.7/.gitignore0000644000175000017500000000654613615410400015521 0ustar winniewinnie# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. #uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # PyPI configuration file .pypirc # NIX /result meshcore_cli-1.5.7/README.md0000644000175000017500000004243713615410400015007 0ustar winniewinnie# meshcore-cli meshcore-cli : CLI interface to MeshCore companion app over BLE, TCP or Serial ## About 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. You can send commands as parameters to the meshcore-cli command (from your shell) either interactively or through a script. 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). By default meshcore-cli interacts with companion radios (through BLE, Serial or TCP). A serial repeater mode has been introduced, which is triggered using `-r`, which mainly adds line editing, history and completion to the serial interface, adds time syncing through `clock sync` and let you download/upload regions specs from a file. 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. ## Install 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:
$ pipx install meshcore-cli
It will install you `meshcore-cli` and `meshcli`, which is an alias to the former. You can use the flake under [nix](https://nixos.org/):
$ nix run github:meshcore-dev/meshcore-cli#meshcore-cli
If you want meshcore-cli to remember last BLE device, you should have some `$HOME/.config/meshcore` where configuration for meschcore-cli will be stored (if not it will use first device it finds). ## Usage
$ meshcli <args> <commands>
If using BLE, don't forget to pair your device first (using `bluetoothctl` for instance on Linux) or meshcli won't be able to communicate. There is a device selector for BLE, you'll just have to use `meshcli -S` to select your device, subsequent calls to meshcli will be send to that device. ### Configuration Configuration files are stored in `$HOME/.config/meshcore` If the directory exists, default BLE address and history will be stored there. If there is an initialization script file called `init`, it will be executed just before the commands provided on command line are executed (and after evaluation of the arguments). Init files can also be defined for a given device, meshcore-cli will look for `.init` file in configuration directory (useful to specify timeout for contacts that are behind bridges with `contact_timeout` command). ### Arguments Arguments mostly deals with connection to the node
    -h : prints this help
    -v : prints version
    -j : json output (disables init file)
    -D : debug
    -S : scan for devices and show a selector
    -l : list available ble/serial devices and exit
    -T <timeout>    : timeout for the ble scan (-S and -l) default 2s
    -a <address>    : specifies device address (can be a name)
    -d <name>       : filter meshcore devices with name or address
    -P              : forces pairing via the OS
    -t <hostname>   : connects via tcp/ip
    -p <port>       : specifies tcp port (default 5000)
    -s <port>       : use serial port <port>
    -b <baudrate>   : specify baudrate
    -C              : toggles classic mode for prompt
    -c <on/off>     : disables most of color output if off
### Available Commands Commands are given after arguments, they can be chained and some have shortcuts. Also prefixing a command with a dot `.` will force it to output json instead of synthetic result.
    ?<cmd> may give you some more help about cmd
  General commands
    chat                   : enter the chat (interactive) mode
    chat_to <ct>           : enter chat with contact                to
    script <filename>      : execute commands in filename
    infos                  : print informations about the node      i
    self_telemetry         : print own telemtry                     t
    card                   : export this node URI                   e
    ver                    : firmware version                       v
    reboot                 : reboots node
    sleep <secs>           : sleeps for a given amount of secs      s
    wait_key               : wait until user presses <Enter>        wk
    apply_to <f> <cmds>    : sends cmds to contacts matching f      at
  Messaging
    msg <name> <msg>       : send message to node by name           m  {
    wait_ack               : wait an ack                            wa }
    chan <nb> <msg>        : send message to channel number <nb>    ch
    public <msg>           : send message to public channel (0)     dch
    recv                   : reads next msg                         r
    wait_msg               : wait for a message and read it         wm
    sync_msgs              : gets all unread msgs from the node     sm
    msgs_subscribe         : display msgs as they arrive            ms
    get_channels           : prints all channel info
    get_channel <n>        : get info for channel (by number or name)
    set_channel n nm k     : set channel info (nb, name, key)
    remove_channel <n>     : remove channel (by number or name)
    scope <s>              : sets node's flood scope
  Management
    advert                 : sends advert                           a
    floodadv               : flood advert
    get <param>            : gets a param, \"get help\" for more
    set <param> <value>    : sets a param, \"set help\" for more
    time <epoch>           : sets time to given epoch
    clock                  : get current time
    clock sync             : sync device clock                      st
    node_discover <filter> : discovers nodes based on their type    nd
  Contacts
    contacts / list        : gets contact list                      lc
    reload_contacts        : force reloading all contacts           rc
    contact_info <ct>      : prints information for contact ct      ci
    contact_timeout <ct> v : sets temp default timeout for contact
    share_contact <ct>     : share a contact with others            sc
    export_contact <ct>    : get a contact's URI                    ec
    import_contact <URI>   : import a contact from its URI          ic
    remove_contact <ct>    : removes a contact from this node
    path <ct>              : diplays path for a contact
    disc_path <ct>         : discover new path and display          dp
    reset_path <ct>        : resets path to a contact to flood      rp
    change_path <ct> <pth> : change the path to a contact           cp
    change_flags <ct> <f>  : change contact flags (tel_l|tel_a|star)cf
    req_telemetry <ct>     : prints telemetry data as json          rt
    req_mma <ct>           : requests min/max/avg for a sensor      rm
    req_acl <ct>           : requests access control list for sensor
    pending_contacts       : show pending contacts
    add_pending <pending>  : manually add pending contact
    flush_pending          : flush pending contact list
  Repeaters
    login <name> <pwd>     : log into a node (rep) with given pwd   l
    logout <name>          : log out of a repeater
    cmd <name> <cmd>       : sends a command to a repeater (no ack) c  [
    wmt8                   : wait for a msg (reply) with a timeout     ]
    req_status <name>      : requests status from a node            rs
    req_neighbours <name>  : requests for neighbours in binary form rn
    trace <path>           : run a trace, path is comma separated
### Interactive Mode aka Instant Message or chat mode ... Chat mode lets you interactively interact with your node or remote nodes. It is automatically triggered when no option is given on the command line. You'll get a prompt with the name of your node. From here you can type meshcore-cli commands. The prompt has history and a basic completion (pressing tab will display possible command or argument options). The `to` command is specific to chat mode, it lets you enter the recipient for next command. By default you're on your node but you can enter other nodes or public rooms. Here are some examples : - `to ` : will enter dest (node or channel) - `to /`, `to ~` : will go to the root (your node) - `to ..` : will go to the last node (it will switch between the two last nodes, this is just a 1-depth history) - `to !` : will switch to the node you received last message from When you are in a node, the behaviour will depend on the node type, if you're on a chat node, it will send messages by default and you can chat. On a repeater or a room server, it will send commands (autocompletion has been set to comply with the CommonCli class of meshcore). To send a message through a room you'll have to prefix the message with a quote or use the send command. The `/` character is used to bypass the node you have currently selected using `to`: - `/` issues cmd command on the root - `//` will send cmd to selected node - `/ ` will send msg to dest (channel or node) #### Flood Scope in interactive mode 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. When entering chat mode, scope will be reset to `*`, meaning classic flood. You can switch scope using the `scope` command, or postfixing the `to` command with `%`. 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. #### Channel echoes It's sometimes interesting to know the path taken by a message received from a channel or which repeaters have repeated a sent message. The app give you the information by listening `rx_log` from the device, when obtained the information is attached to the message and can be read. In meshcore-cli I went lower-level by implementing channel echoes. When activated (with `/set channel_echoes on`), all the channel messages will be printed on the terminal along with the SNR and path taken. When sending a message, you'll have all the repeats from 0-hop repeaters as echoes, and when a message is received, you should see information about the received message, but also all the instances of the same message that might have reached you from another path. In the example below, a msg has been sent between two repeaters, 21 and 25. 25 repeated the message and 21 the repeat and both echoes came back to the node with different SNRs. ``` f1down/#fdl|*> 8 #fdl f1down: 8 [25] -4.75-112 #fdl f1down: 8 [2521] 1.00-109 ``` ### Contact management 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). 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. 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). This feature only really works in interactive mode. 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). ### Issuing batch commands to contacts with apply to `apply_to ` : applies cmd to contacts matching filter `` it can be used to apply the same command to a pool of repeaters, or remove some contacts matching a condition. Filter is constructed with comma separated fields : - `u`, matches modification time `<` or `>` than a timestamp (can also be days hours or minutes ago if followed by `d`,`h` or `m`) - `t`, matches the type (1: client, 2: repeater, 3: room, 4: sensor) - `h`, matches number of hops - `d`, direct, similar to `h>-1` - `f`, flood, similar to `h<0` or `h=-1` Commands should be written as if in interactive mode, if writing from the commandline don't forget to use commas to clearly delimit fields. Note: Some commands like `contact_name` (aka `cn`), `reset_path` (aka `rp`), `forget_password` (aka `fp`) can be chained. There is also a `sleep` command taking an optional time parameter. The sleep will be issued after the command, it helps limiting rate through repeaters ... #### Examples ``` # removes all clients that have not been updated in last 2 days at u<2d,t=1 remove_contact # gives traces to repeaters that have been updated in the last 24h and are direct at t=2,u>1d,d cn trace # tries to do flood login to all repeaters at t=2 rp login ``` ## Examples
# gets info from first ble MC device it finds (was -s but now used for serial port)
$ meshcore-cli -d "" infos
INFO:meshcore:Scanning for devices
INFO:meshcore:Found device : C2:2B:A1:D5:3E:B6: MeshCore-t114_fdl
INFO:meshcore:BLE Connection started
{
    "adv_type": 1,
    "tx_power": 22,
    "max_tx_power": 22,
    "public_key": "993acd42fc779962c68c627829b32b111fa27a67d86b75c17460ff48c3102db4",
    "adv_lat": 47.794,
    "adv_lon": -3.428,
    "radio_freq": 869.525,
    "radio_bw": 250.0,
    "radio_sf": 11,
    "radio_cr": 5,
    "name": "t114_fdl"
}

# getting time
$ meshcli -a C2:2B:A1:D5:3E:B6 clock
INFO:meshcore:BLE Connection started
Current time : 2025-04-18 08:19:26 (1744957166)

# If you're familiar with meshcli, you should have noted that 
# now output is not json only, to get json output, use -j 
# or prefix your commands with a dot
$ meshcli -a C2:2B:A1:D5:3E:B6 .clock
INFO:meshcore:BLE Connection started
{
    "time": 1744957249
}

# Using -j, meshcli will return replies in json format ...
$ meshcli -j -a C2:2B:A1:D5:3E:B6 clock
{
    "time": 1744957261
}

# So if I reboot the node, and want to set time, I can chain the commands
# and get that kind of output (even better by feeding it to jq)
$ meshcli reboot
INFO:meshcore:BLE Connection started
$ meshcli -j clock clock sync clock | jq -c
{ "time": 1715770371 }
{ "ok": "time synced" }
{ "time": 1745996105 }

# Now check if time is ok with human output (I don't read epoch time yet)
$ meshcli clock
INFO:meshcore:BLE Connection started
Current time : 2025-04-30 08:56:27 (1745996187)

# Now you'll probably want to send some messages ... 
# For that, there is the msg command, wait_ack
$ meshcli msg Techo_fdl "Hello T-Echo" wa
INFO:meshcore:BLE Connection started
Msg acked

# I can check the message on the techo
$ meshcli -d Techo sm
INFO:meshcore:Scanning for devices
INFO:meshcore:Found device : DE:B6:D0:68:D5:62: MeshCore-Techo_fdl
INFO:meshcore:BLE Connection started
t114_fdl(0): Hello T-Echo

# And reply using json output for more verbosity
# here I've used jq with -cs to get a compact array
$ meshcli msg t114_fdl hello wa | jq -cs
[{"type":0,"expected_ack":"4802ed93","suggested_timeout":2970},{"code":"4802ed93"}]

# But this could have been done interactively using the chat mode
# Here from the techo side. Note that un-acked messages will be
# signaled with an ! at the start of the prompt (or red color in color mode)
$ meshcli chat
INFO:meshcore:BLE Connection started
Interactive mode, most commands from terminal chat should work.
Use "to" to selects contact, "list" to list contacts, "send" to send a message ...
Line starting with "$" or "." will issue a meshcli command.
"quit" or "q" will end interactive mode
t114_fdl(D): Hello T-Echo
EnsibsRoom> Hi
!EnsibsRoom> to t114_fdl
t114_fdl> Hi
t114_fdl(D): It took you long to reply ...
t114_fdl> I forgot to set the recipient with the to command
t114_fdl(D): It happens ...
t114_fdl> 

# Loging into repeaters and sending commands is also possible
# directly from the chat, because we can use meshcli commands ;)
$ meshcli chat (pending msgs are shown at connexion ...)
INFO:meshcore:BLE Connection started
Interactive mode, most commands from terminal chat should work.
Use "to" to selects contact, "list" to list contacts, "send" to send a message ...
Line starting with "$" or "." will issue a meshcli command.
"quit" or "q" will end interactive mode
Techo_fdl(0): Cool to receive some msgs from you
Techo_fdl(D): Hi
Techo_fdl(D): I forgot to set the recipient with the to command
FdlRoom> login password
Login success
FdlRoom> clock
FdlRoom(0): 06:40 - 18/4/2025 UTC
FdlRoom>
meshcore_cli-1.5.7/flake.nix0000644000175000017500000000334013615410400015320 0ustar winniewinnie{ inputs = { flake-utils.url = "github:numtide/flake-utils"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; outputs = inputs: inputs.flake-utils.lib.eachDefaultSystem ( system: let pkgs = inputs.nixpkgs.legacyPackages.${system}; lib = pkgs.lib; inherit (pkgs) python3Packages; meshcore = python3Packages.buildPythonPackage rec { pname = "meshcore"; version = "2.3.6"; pyproject = true; src = python3Packages.fetchPypi { inherit pname version; sha256 = "sha256-l9/wDgRfzPSZGIt+tIUITs/mf/AeEIMDN9DXAbMSzSs="; }; build-system = [ python3Packages.hatchling ]; dependencies = [ python3Packages.bleak python3Packages.pycayennelpp python3Packages.pyserial-asyncio-fast python3Packages.pycryptodome ]; pythonImportsCheck = [ "meshcore" ]; }; pyproject = lib.importTOML ./pyproject.toml; version = pyproject.project.version; in { packages.meshcore-cli = python3Packages.buildPythonPackage { pname = "meshcore-cli"; inherit version; src = ./.; pyproject = true; nativeBuildInputs = [ python3Packages.hatchling python3Packages.setuptools python3Packages.wheel ]; propagatedBuildInputs = [ meshcore python3Packages.click python3Packages.prompt_toolkit python3Packages.pyserial python3Packages.requests python3Packages.pycryptodome ]; doCheck = false; }; } ); } meshcore_cli-1.5.7/REPEATER_COMMANDS.md0000644000175000017500000002727013615410400016540 0ustar winniewinnie# Repeater Commands Reference meshcore-cli can interact with repeaters through two fundamentally different paths, each exposing a different set of commands. ## Connection Paths ### Serial direct (`-r -s `) Connects USB directly to the repeater hardware. The CLI talks to the firmware's text CLI โ€” raw text in, raw text out over UART. This gives access to the firmware's full serial command set. ### Client mode (`to `) Connects to your companion client node (via BLE, TCP or serial without `-r`), then sends commands to the repeater over the mesh radio network using encrypted MeshCore `cmd` messages. The repeater firmware processes these through its mesh command handler, which is more limited than the serial interface. The transport between you and your companion node (BLE, TCP or serial) does not matter โ€” all three use the same MeshCore API and the same mesh protocol to reach the repeater.
                          +------------------+
  -r -s (serial)  ------->| Firmware text CLI |  (raw UART)
                          |   on repeater    |
                          +------------------+

                          +--------------+        +-------------------+
  BLE -----+              |              |  mesh  | Firmware mesh cmd |
  TCP -----+--> MeshCore --> Companion   +------->| handler on        |
  Serial --+  (same API) |   node       |  radio | repeater          |
                          +--------------+        +-------------------+
## Commands Available in Both Modes The following commands work regardless of how you connect to the repeater. In serial mode they are sent as raw text. In client mode they are wrapped in a `cmd` message and sent over the mesh. ### Information
    ver                     - Firmware version
    board                   - Board name
    clock                   - Show current time
### Statistics
    stats-core              - Core stats (uptime, battery, queue)
    stats-radio             - Radio stats (RSSI, SNR, noise floor)
    stats-packets           - Packet statistics (sent/recv counts)
    clear stats             - Reset all statistics
### Network
    neighbors               - Show neighboring repeaters (zero-hop)
    neighbor.remove      - Remove a specific neighbor
    discover.neighbors      - Actively discover neighbors
    advert                  - Send advertisement now
### Logging
    log start               - Enable packet logging
    log stop                - Disable packet logging
Note: after `log start` in client mode, log data streams back as `RX_LOG_DATA` events and is processed with rich packet parsing (headers, routes, paths, channel/advert echoes). In serial mode, log data is printed as raw firmware text. ### Configuration (get/set)
  get name                  - Node name
  get role                  - Node role
  get radio                 - Radio params (freq,bw,sf,cr)
  get freq                  - Frequency
  get tx                    - TX power (dBm)
  get af                    - Antenna factor
  get repeat                - Repeat mode on/off
  get public.key            - Node public key
  get lat                   - Latitude
  get lon                   - Longitude
  get advert.interval       - Advertisement interval (minutes)
  get flood.advert.interval - Flood advertisement interval
  get flood.max             - Maximum flood hops
  get guest.password        - Guest password
  get allow.read.only       - Read-only access mode
  get owner.info            - Owner information
  get acl                   - Access control list
  get rxdelay               - RX delay
  get txdelay               - TX delay
  get direct.txdelay        - Direct TX delay

  set name            - Set node name
  set radio f,bw,sf,cr      - Set radio params (reboot to apply)
  set freq            - Set frequency
  set tx             - Set TX power (dBm)
  set af             - Set antenna factor
  set repeat on|off         - Enable/disable repeating
  set lat            - Set latitude
  set lon            - Set longitude
  set advert.interval  - Set advert interval (60-240 min)
  set flood.advert.interval  - Set flood advert interval
  set flood.max      - Set max flood hops
  set guest.password   - Set guest password
  set allow.read.only on|off - Set read-only access
  set owner.info      - Set owner information
  set rxdelay        - Set RX delay
  set txdelay        - Set TX delay
  set direct.txdelay   - Set direct TX delay
### Bridge Configuration (get/set)
  get bridge.enabled        - Bridge enabled state
  get bridge.delay          - Bridge delay
  get bridge.source         - Bridge source
  get bridge.baud           - Bridge baud rate
  get bridge.secret         - Bridge secret

  set bridge.enabled on|off - Enable/disable bridge
  set bridge.delay   - Set bridge delay
  set bridge.source  - Set bridge source
  set bridge.baud    - Set bridge baud rate
  set bridge.secret  - Set bridge secret
### Region Management
  region                    - Display currently configured regions
  region save               - Save current region config to flash
  region home               - Get/set home region
  region get                - Get info (and parent) for a region
  region put                - Add or update a region
  region remove             - Remove a region definition
  region allowf             - Give flood permission to a region
  region denyf              - Remove flood permission from a region
### GPS
  gps on|off                - Enable/disable GPS
  gps sync                  - Sync GPS
  gps setloc                - Set location from GPS
  gps advert none|share|prefs - GPS advertisement mode
### Sensors
  sensor list               - List sensors
  sensor get                - Get sensor value
  sensor set                - Set sensor value
### Other
  password             - Set admin password
  powersaving on|off        - Toggle power saving mode
  setperm        - Set permissions for a node
  time               - Set time to given epoch
  reboot                    - Reboot device
  erase                     - Erase filesystem
## Commands Available Only in Serial Mode (`-r`) These commands only work over the serial text CLI. They return "Unknown command" when sent via mesh `cmd`. ### Logging (serial only)
  log                       - Dump stored log file to console
  log erase                 - Erase log file
The bare `log` command streams stored log data over the serial output, which doesn't fit the mesh command request/response model. `log erase` is similarly a serial-only operation. ### Region File Transfer (serial only) These are meshcore-cli convenience commands that handle file I/O over the serial link:
  region upload       - Upload regions config from local file to node
  region load         - Alias for region upload
  region download     - Download regions config from node to local file
  region list               - List allowed/denied regions
### Other Serial-Only Commands
  tempradio                 - Temporary radio configuration
  script              - Execute a local script file (lines sent one by one)
  clock sync                - Sync repeater clock to host time (alias: st, sync_time)
Note: `clock sync` in serial mode is intercepted by meshcore-cli, which reads the host clock and sends `time ` to the firmware. In client mode, `clock sync` is available as a meshcore-cli command on the companion node (not sent to the repeater). ## Commands Available Only in Client Mode (`to `) These are meshcore-cli commands that use the MeshCore protocol to query repeaters. They are not raw firmware commands โ€” they use dedicated binary protocol messages. ### Repeater Management
  login                - Log into repeater with password          l
  logout                    - Log out of repeater
  req_status                - Request status from repeater             rs
  req_neighbours            - Request neighbours in binary form        rn
  req_regions               - Request regions list                     rr
  req_owner                 - Request owner information                ro
  req_clock                 - Request repeater timestamp (for sync)
  req_acl                   - Request access control list              ra
  trace                     - Run a trace to this repeater             tr
  dtrace                    - Discover path and trace                  dt
### Contact Operations (on the repeater contact)
  contact_info              - Print contact info for this repeater     ci
  path                      - Display path to this repeater
  disc_path                 - Discover new path and display            dp
  reset_path                - Reset path to flood                      rp
  change_path         - Change the path to this repeater         cp
  change_flags       - Change contact flags                     cf
  share_contact             - Share this repeater's contact            sc
  export_contact            - Export this repeater's URI               ec
  req_telemetry             - Request telemetry data                   rt
  forget_password           - Remove stored password for repeater      fp
  set timeout        - Set command timeout for this repeater
  get timeout               - Get command timeout for this repeater
### Special Operations
  clkreboot                 - Clock-aware reboot
  start ota                 - Start OTA (over-the-air) update
  get telemetry             - Alias for req_telemetry
  get status                - Alias for req_status
  get acl                   - Alias for req_acl
### Prefix Shortcuts
  :                    - Force send as raw cmd (e.g. ":ver")
  send  / "       - Send a text message to repeater (room)
## Quick Reference Table | Command | Serial (`-r`) | Client (`to`) | Notes | | --- | :---: | :---: | --- | | ver, board, clock | Yes | Yes | | | stats-core/radio/packets | Yes | Yes | | | clear stats | Yes | Yes | | | neighbors | Yes | Yes | | | neighbor.remove | Yes | Yes | | | discover.neighbors | Yes | Yes | | | advert | Yes | Yes | | | log start / log stop | Yes | Yes | Client receives data via RX_LOG_DATA events | | log (dump) | Yes | **No** | Serial streaming, no mesh equivalent | | log erase | Yes | **No** | Serial only | | get/set (all params) | Yes | Yes | | | region (get/put/remove/save/home/allowf/denyf) | Yes | Yes | | | region upload/download | Yes | **No** | Requires serial file transfer | | region list | Yes | **No** | Serial only | | gps | Yes | Yes | | | sensor | Yes | Yes | | | password | Yes | Yes | | | powersaving | Yes | Yes | | | setperm | Yes | Yes | Client mode resolves contact names to keys | | time, reboot, erase | Yes | Yes | | | tempradio | Yes | **No** | Serial only | | clock sync | Yes | **No** | CLI intercepts and sends `time ` | | script | Yes | **No** | Reads local file, sends lines over serial | | login/logout | **No** | Yes | MeshCore protocol commands | | req_status | **No** | Yes | Binary protocol request | | req_neighbours | **No** | Yes | Binary protocol request | | req_regions | **No** | Yes | Binary protocol request | | req_owner | **No** | Yes | Binary protocol request | | req_clock | **No** | Yes | Binary protocol request | | req_acl | **No** | Yes | Binary protocol request | | trace/dtrace | **No** | Yes | Path tracing via mesh | | contact management | **No** | Yes | CLI-level operations on companion node | | clkreboot, start ota | **No** | Yes | Mesh protocol commands |