├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml └── src └── meshcore_cli ├── __init__.py ├── __main__.py └── meshcore_cli.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc 172 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 fdlamotte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # meshcore-cli 2 | 3 | meshcore-cli : CLI interface to MeschCore companion app over BLE, TCP or Serial 4 | 5 | ## Install 6 | 7 | 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 : 8 | 9 |
10 | $ pipx install meshcore-cli 11 |12 | 13 | It will install you `meshcore-cli` and `meshcli`, which is an alias to the former. 14 | 15 | 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). 16 | 17 | ## Usage 18 | 19 |
20 | $ meshcli <args> <commands> 21 |22 | 23 | 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. 24 | 25 | ### Configuration 26 | 27 | Configuration files are stored in ```$HOME/.config/meshcore``` 28 | 29 | If the directory exists, default ble address and history will be stored there. 30 | 31 | 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). 32 | 33 | ### Arguments 34 | 35 | Arguments mostly deals with ble connection 36 | 37 |
38 | -h : prints this help 39 | -j : json output 40 | -D : print debug messages 41 | -S : BLE device selector 42 | -l : lists BLE devices 43 | -a <address> : specifies device address (can be a name) 44 | -d <name> : filter meshcore devices with name or address 45 | -t <hostname> : connects via tcp/ip 46 | -p <port> : specifies tcp port (default 5000) 47 | -s <port> : use serial port <port> 48 | -b <baudrate> : specify baudrate 49 |50 | 51 | ### Available Commands 52 | 53 | 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. 54 | 55 |
56 | General commands 57 | chat : enter the chat (interactive) mode 58 | chat_to <ct> : enter chat with contact to 59 | script <filename> : execute commands in filename 60 | infos : print informations about the node i 61 | card : export this node URI e 62 | ver : firmware version v 63 | reboot : reboots node 64 | sleep <secs> : sleeps for a given amount of secs s 65 | wait_key : wait until user presses <Enter> wk 66 | Messenging 67 | msg <name> <msg> : send message to node by name m { 68 | wait_ack : wait an ack wa } 69 | chan <nb> <msg> : send message to channel number <nb> ch 70 | public <msg> : send message to public channel (0) dch 71 | recv : reads next msg r 72 | wait_msg : wait for a message and read it wm 73 | sync_msgs : gets all unread msgs from the node sm 74 | msgs_subscribe : display msgs as they arrive ms 75 | Management 76 | advert : sends advert a 77 | floodadv : flood advert 78 | get <param> : gets a param, "get help" for more 79 | set <param> <value> : sets a param, "set help" for more 80 | time <epoch> : sets time to given epoch 81 | clock : get current time 82 | clock sync : sync device clock st 83 | cli : send a cmd to node's cli (if avail) @ 84 | Contacts 85 | contacts / list : gets contact list lc 86 | share_contact <ct> : share a contact with others sc 87 | export_contact <ct> : get a contact's URI ec 88 | import_contact <URI> : import a contactt from its URI ic 89 | remove_contact <ct> : removes a contact from this node 90 | reset_path <ct> : resets path to a contact to flood rp 91 | change_path <ct> <pth> : change the path to a contact cp 92 | change_flags <ct> <f> : change contact flags (tel_l|tel_a|star)cf 93 | req_telemetry <ct> : prints telemetry data as json rt 94 | Repeaters 95 | login <name> <pwd> : log into a node (rep) with given pwd l 96 | logout <name> : log out of a repeater 97 | cmd <name> <cmd> : sends a command to a repeater (no ack) c [ 98 | wmt8 : wait for a msg (reply) with a timeout ] 99 | req_status <name> : requests status from a node rs 100 |101 | 102 | ### Interactive Mode 103 | 104 | aka Instant Message or chat mode ... 105 | 106 | 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. 107 | 108 | 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). 109 | 110 | 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 : 111 | * `to
123 | # gets info from first ble MC device it finds (was -s but now used for serial port) 124 | $ meshcore-cli -d "" infos 125 | INFO:meshcore:Scanning for devices 126 | INFO:meshcore:Found device : C2:2B:A1:D5:3E:B6: MeshCore-t114_fdl 127 | INFO:meshcore:BLE Connection started 128 | { 129 | "adv_type": 1, 130 | "tx_power": 22, 131 | "max_tx_power": 22, 132 | "public_key": "993acd42fc779962c68c627829b32b111fa27a67d86b75c17460ff48c3102db4", 133 | "adv_lat": 47.794, 134 | "adv_lon": -3.428, 135 | "radio_freq": 869.525, 136 | "radio_bw": 250.0, 137 | "radio_sf": 11, 138 | "radio_cr": 5, 139 | "name": "t114_fdl" 140 | } 141 | 142 | # getting time 143 | $ meshcli -a C2:2B:A1:D5:3E:B6 clock 144 | INFO:meshcore:BLE Connection started 145 | Current time : 2025-04-18 08:19:26 (1744957166) 146 | 147 | # If you're familiar with meshcli, you should have noted that 148 | # now output is not json only, to get json output, use -j 149 | # or prefix your commands with a dot 150 | $ meshcli -a C2:2B:A1:D5:3E:B6 .clock 151 | INFO:meshcore:BLE Connection started 152 | { 153 | "time": 1744957249 154 | } 155 | 156 | # Using -j, meshcli will return replies in json format ... 157 | $ meshcli -j -a C2:2B:A1:D5:3E:B6 clock 158 | { 159 | "time": 1744957261 160 | } 161 | 162 | # So if I reboot the node, and want to set time, I can chain the commands 163 | # and get that kind of output (even better by feeding it to jq) 164 | $ meshcli reboot 165 | INFO:meshcore:BLE Connection started 166 | $ meshcli -j clock clock sync clock | jq -c 167 | { "time": 1715770371 } 168 | { "ok": "time synced" } 169 | { "time": 1745996105 } 170 | 171 | # Now check if time is ok with human output (I don't read epoch time yet) 172 | $ meshcli clock 173 | INFO:meshcore:BLE Connection started 174 | Current time : 2025-04-30 08:56:27 (1745996187) 175 | 176 | # Now you'll probably want to send some messages ... 177 | # For that, there is the msg command, wait_ack 178 | $ meshcli msg Techo_fdl "Hello T-Echo" wa 179 | INFO:meshcore:BLE Connection started 180 | Msg acked 181 | 182 | # I can check the message on the techo 183 | $ meshcli -d Techo sm 184 | INFO:meshcore:Scanning for devices 185 | INFO:meshcore:Found device : DE:B6:D0:68:D5:62: MeshCore-Techo_fdl 186 | INFO:meshcore:BLE Connection started 187 | t114_fdl(0): Hello T-Echo 188 | 189 | # And reply using json output for more verbosity 190 | # here I've used jq with -cs to get a compact array 191 | $ meshcli msg t114_fdl hello wa | jq -cs 192 | [{"type":0,"expected_ack":"4802ed93","suggested_timeout":2970},{"code":"4802ed93"}] 193 | 194 | # But this could have been done interactively using the chat mode 195 | # Here from the techo side. Note that un-acked messages will be 196 | # signaled with an ! at the start of the prompt (or red color in color mode) 197 | $ meshcli chat 198 | INFO:meshcore:BLE Connection started 199 | Interactive mode, most commands from terminal chat should work. 200 | Use "to" to selects contact, "list" to list contacts, "send" to send a message ... 201 | Line starting with "$" or "." will issue a meshcli command. 202 | "quit" or "q" will end interactive mode 203 | t114_fdl(D): Hello T-Echo 204 | EnsibsRoom> Hi 205 | !EnsibsRoom> to t114_fdl 206 | t114_fdl> Hi 207 | t114_fdl(D): It took you long to reply ... 208 | t114_fdl> I forgot to set the recipient with the to command 209 | t114_fdl(D): It happens ... 210 | t114_fdl> 211 | 212 | # Loging into repeaters and sending commands is also possible 213 | # directly from the chat, because we can use meshcli commands ;) 214 | $ meshcli chat (pending msgs are shown at connexion ...) 215 | INFO:meshcore:BLE Connection started 216 | Interactive mode, most commands from terminal chat should work. 217 | Use "to" to selects contact, "list" to list contacts, "send" to send a message ... 218 | Line starting with "$" or "." will issue a meshcli command. 219 | "quit" or "q" will end interactive mode 220 | Techo_fdl(0): Cool to receive some msgs from you 221 | Techo_fdl(D): Hi 222 | Techo_fdl(D): I forgot to set the recipient with the to command 223 | FdlRoom> login password 224 | Login success 225 | FdlRoom> clock 226 | FdlRoom(0): 06:40 - 18/4/2025 UTC 227 | FdlRoom> 228 |229 | 230 | 231 | 232 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "meshcore-cli" 7 | version = "1.0.1" 8 | authors = [ 9 | { name="Florent de Lamotte", email="florent@frizoncorrea.fr" }, 10 | ] 11 | description = "Command line interface to meshcore companion radios" 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "Operating System :: OS Independent", 17 | ] 18 | license = "MIT" 19 | license-files = ["LICEN[CS]E*"] 20 | dependencies = [ "meshcore >= 1.9.10", "prompt_toolkit >= 3.0.50", "requests >= 2.28.0" ] 21 | 22 | [project.urls] 23 | Homepage = "https://github.com/fdlamotte/meshcore-cli" 24 | Issues = "https://github.com/fdlamotte/meshcore-cli/issues" 25 | 26 | [project.scripts] 27 | meshcli = "meshcore_cli.meshcore_cli:cli" 28 | meshcore-cli = "meshcore_cli.meshcore_cli:cli" 29 | -------------------------------------------------------------------------------- /src/meshcore_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fdlamotte/meshcore-cli/c26bfbf1b227ee84e4caff392bc5fbf012bbd5eb/src/meshcore_cli/__init__.py -------------------------------------------------------------------------------- /src/meshcore_cli/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from meshcore_cli.meshcore_cli import cli 3 | cli() 4 | -------------------------------------------------------------------------------- /src/meshcore_cli/meshcore_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """ 3 | mccli.py : CLI interface to MeschCore BLE companion app 4 | """ 5 | import asyncio 6 | import os, sys 7 | import time, datetime 8 | import getopt, json, shlex, re 9 | import logging 10 | import requests 11 | from bleak import BleakScanner 12 | from pathlib import Path 13 | from prompt_toolkit.shortcuts import PromptSession 14 | from prompt_toolkit.shortcuts import CompleteStyle 15 | from prompt_toolkit.completion import NestedCompleter 16 | from prompt_toolkit.history import FileHistory 17 | from prompt_toolkit.formatted_text import ANSI 18 | from prompt_toolkit.key_binding import KeyBindings 19 | from prompt_toolkit.shortcuts import radiolist_dialog 20 | 21 | from meshcore import TCPConnection, BLEConnection, SerialConnection 22 | from meshcore import MeshCore, EventType, logger 23 | 24 | # Version 25 | VERSION = "v1.0.1" 26 | 27 | # default ble address is stored in a config file 28 | MCCLI_CONFIG_DIR = str(Path.home()) + "/.config/meshcore/" 29 | MCCLI_ADDRESS = MCCLI_CONFIG_DIR + "default_address" 30 | MCCLI_HISTORY_FILE = MCCLI_CONFIG_DIR + "history" 31 | MCCLI_INIT_SCRIPT = MCCLI_CONFIG_DIR + "init" 32 | 33 | # Fallback address if config file not found 34 | # if None or "" then a scan is performed 35 | ADDRESS = "" 36 | JSON = False 37 | 38 | PS = None 39 | CS = None 40 | 41 | # Ansi colors 42 | ANSI_END = "\033[0m" 43 | ANSI_INVERT = "\033[7m" 44 | ANSI_NORMAL = "\033[27m" 45 | ANSI_GREEN = "\033[0;32m" 46 | ANSI_BGREEN = "\033[1;32m" 47 | ANSI_BLUE = "\033[0;34m" 48 | ANSI_BBLUE = "\033[1;34m" 49 | ANSI_YELLOW = "\033[0;33m" 50 | ANSI_BYELLOW = "\033[1;33m" 51 | ANSI_RED = "\033[0;31m" 52 | ANSI_BRED = "\033[1;31m" 53 | ANSI_MAGENTA = "\033[0;35m" 54 | ANSI_BMAGENTA = "\033[1;35m" 55 | ANSI_CYAN = "\033[0;36m" 56 | ANSI_BCYAN = "\033[1;36m" 57 | ANSI_LIGHT_BLUE = "\033[0;94m" 58 | ANSI_LIGHT_GREEN = "\033[0;92m" 59 | ANSI_LIGHT_YELLOW = "\033[0;93m" 60 | ANSI_LIGHT_GRAY="\033[0;38;5;247m" 61 | ANSI_BGRAY="\033[1;38;5;247m" 62 | 63 | def escape_ansi(line): 64 | ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]') 65 | return ansi_escape.sub('', line) 66 | 67 | def print_above(str): 68 | """ prints a string above current line """ 69 | width = os.get_terminal_size().columns 70 | stringlen = len(escape_ansi(str))-1 71 | lines = divmod(stringlen, width)[0] + 1 72 | print("\u001B[s", end="") # Save current cursor position 73 | print("\u001B[A", end="") # Move cursor up one line 74 | print("\u001B[999D", end="") # Move cursor to beginning of line 75 | for _ in range(lines): 76 | print("\u001B[S", end="") # Scroll up/pan window down 1 line 77 | print("\u001B[L", end="") # Insert new line 78 | for _ in range(lines - 1): 79 | print("\u001B[A", end="") # Move cursor up one line 80 | print(str, end="") # Print output status msg 81 | print("\u001B[u", end="", flush=True) # Jump back to saved cursor position 82 | 83 | async def process_event_message(mc, ev, json_output, end="\n", above=False): 84 | """ display incoming message """ 85 | if ev is None : 86 | logger.error("Event does not contain message.") 87 | elif ev.type == EventType.NO_MORE_MSGS: 88 | logger.debug("No more messages") 89 | return False 90 | elif ev.type == EventType.ERROR: 91 | logger.error(f"Error retrieving messages: {ev.payload}") 92 | return False 93 | elif json_output : 94 | if above : 95 | print_above(json.dumps(ev.payload)) 96 | else: 97 | print(json.dumps(ev.payload), end=end, flush=True) 98 | else : 99 | await mc.ensure_contacts() 100 | data = ev.payload 101 | 102 | if data['path_len'] == 255 : 103 | path_str = "D" 104 | else : 105 | path_str = f"{data['path_len']}" 106 | if "SNR" in data and process_event_message.print_snr: 107 | path_str = path_str + f",{data['SNR']}dB" 108 | 109 | if (data['type'] == "PRIV") : 110 | ct = mc.get_contact_by_key_prefix(data['pubkey_prefix']) 111 | if ct is None: 112 | logger.debug(f"Unknown contact with pubkey prefix: {data['pubkey_prefix']}") 113 | name = data["pubkey_prefix"] 114 | else: 115 | name = ct["adv_name"] 116 | process_event_message.last_node=ct 117 | 118 | if ct is None: # Unknown 119 | disp = f"{ANSI_RED}" 120 | elif ct["type"] == 3 : # room 121 | disp = f"{ANSI_CYAN}" 122 | elif ct["type"] == 2 : # repeater 123 | disp = f"{ANSI_MAGENTA}" 124 | else: 125 | disp = f"{ANSI_BLUE}" 126 | disp = disp + f"{name}" 127 | if 'signature' in data: 128 | sender = mc.get_contact_by_key_prefix(data['signature']) 129 | if sender is None: 130 | disp = disp + f"/{ANSI_RED}{data['signature']}" 131 | else: 132 | disp = disp + f"/{ANSI_BLUE}{sender['adv_name']}" 133 | disp = disp + f" {ANSI_YELLOW}({path_str})" 134 | if data["txt_type"] == 1: 135 | disp = disp + f"{ANSI_LIGHT_GRAY}" 136 | else: 137 | disp = disp + f"{ANSI_END}" 138 | disp = disp + f": {data['text']}" 139 | 140 | if not process_event_message.color: 141 | disp = escape_ansi(disp) 142 | 143 | if above: 144 | print_above(disp) 145 | else: 146 | print(disp, flush=True) 147 | 148 | elif (data['type'] == "CHAN") : 149 | path_str = f"{ANSI_YELLOW}({path_str}){ANSI_END}" 150 | if data["channel_idx"] == 0: #public 151 | disp = f"{ANSI_GREEN}public {path_str}" 152 | process_event_message.last_node = {"adv_name" : "public", "type" : 0, "chan_nb" : 0} 153 | else : 154 | disp = f"{ANSI_GREEN}ch{data['channel_idx']} {path_str}" 155 | process_event_message.last_node = {"adv_name" : f"ch{data['channel_idx']}", "type" : 0, "chan_nb" : data['channel_idx']} 156 | disp = disp + f"{ANSI_END}" 157 | disp = disp + f": {data['text']}" 158 | 159 | if not process_event_message.color: 160 | disp = escape_ansi(disp) 161 | 162 | if above: 163 | print_above(disp) 164 | else: 165 | print(disp) 166 | else: 167 | print(json.dumps(ev.payload)) 168 | return True 169 | process_event_message.print_snr=False 170 | process_event_message.color=True 171 | process_event_message.last_node=None 172 | 173 | async def handle_message(event): 174 | """ Process incoming message events """ 175 | await process_event_message(handle_message.mc, event, 176 | above=handle_message.above, 177 | json_output=handle_message.json_output) 178 | handle_message.json_output=False 179 | handle_message.mc=None 180 | handle_message.above=False 181 | 182 | async def subscribe_to_msgs(mc, json_output=False, above=False): 183 | """ Subscribe to incoming messages """ 184 | global PS, CS 185 | await mc.ensure_contacts() 186 | handle_message.json_output = json_output 187 | handle_message.above = above 188 | # Subscribe to private messages 189 | if PS is None : 190 | PS = mc.subscribe(EventType.CONTACT_MSG_RECV, handle_message) 191 | # Subscribe to channel messages 192 | if CS is None : 193 | CS = mc.subscribe(EventType.CHANNEL_MSG_RECV, handle_message) 194 | await mc.start_auto_message_fetching() 195 | 196 | def make_completion_dict(contacts, to=None): 197 | contact_list = {} 198 | to_list = {} 199 | 200 | to_list["~"] = None 201 | to_list["/"] = None 202 | if not process_event_message.last_node is None: 203 | to_list["!"] = None 204 | to_list[".."] = None 205 | to_list["public"] = None 206 | 207 | it = iter(contacts.items()) 208 | for c in it : 209 | contact_list[c[1]['adv_name']] = None 210 | 211 | to_list.update(contact_list) 212 | 213 | to_list["ch"] = None 214 | to_list["ch0"] = None 215 | 216 | completion_list = { 217 | "to" : to_list, 218 | "public" : None, 219 | "chan" : None, 220 | } 221 | 222 | if to is None : 223 | completion_list.update({ 224 | "ver" : None, 225 | "infos" : None, 226 | "advert" : None, 227 | "floodadv" : None, 228 | "msg" : contact_list, 229 | "wait_ack" : None, 230 | "time" : None, 231 | "clock" : {"sync" : None}, 232 | "reboot" : None, 233 | "card" : None, 234 | "upload_card" : None, 235 | "contact_info": contact_list, 236 | "export_contact" : contact_list, 237 | "upload_contact" : contact_list, 238 | "path": contact_list, 239 | "reset_path" : contact_list, 240 | "change_path" : contact_list, 241 | "change_flags" : contact_list, 242 | "remove_contact" : contact_list, 243 | "import_contact" : {"meshcore://":None}, 244 | "login" : contact_list, 245 | "cmd" : contact_list, 246 | "req_status" : contact_list, 247 | "logout" : contact_list, 248 | "req_telemetry" : contact_list, 249 | "self_telemetry" : None, 250 | "get_channel": None, 251 | "set_channel": None, 252 | "set" : { 253 | "name" : None, 254 | "pin" : None, 255 | "radio" : {",,,":None, "f,bw,sf,cr":None}, 256 | "tx" : None, 257 | "tuning" : {",", "af,tx_d"}, 258 | "lat" : None, 259 | "lon" : None, 260 | "coords" : None, 261 | "print_snr" : {"on":None, "off": None}, 262 | "json_msgs" : {"on":None, "off": None}, 263 | "color" : {"on":None, "off":None}, 264 | "print_name" : {"on":None, "off":None}, 265 | "classic_prompt" : {"on" : None, "off":None}, 266 | "manual_add_contacts" : {"on" : None, "off":None}, 267 | "telemetry_mode_base" : {"always" : None, "device":None, "never":None}, 268 | "telemetry_mode_loc" : {"always" : None, "device":None, "never":None}, 269 | "telemetry_mode_env" : {"always" : None, "device":None, "never":None}, 270 | }, 271 | "get" : {"name":None, 272 | "bat":None, 273 | "radio":None, 274 | "tx":None, 275 | "coords":None, 276 | "lat":None, 277 | "lon":None, 278 | "print_snr":None, 279 | "json_msgs":None, 280 | "color":None, 281 | "print_name":None, 282 | "classic_prompt":None, 283 | "manual_add_contacts":None, 284 | "telemetry_mode_base":None, 285 | "telemetry_mode_loc":None, 286 | "telemetry_mode_env":None, 287 | "custom":None 288 | }, 289 | }) 290 | completion_list["set"].update(make_completion_dict.custom_vars) 291 | completion_list["get"].update(make_completion_dict.custom_vars) 292 | else : 293 | completion_list.update({ 294 | "send" : None, 295 | }) 296 | 297 | if to['type'] > 0: # contact 298 | completion_list.update({ 299 | "contact_info": None, 300 | "path": None, 301 | "export_contact" : None, 302 | "upload_contact" : None, 303 | "reset_path" : None, 304 | "change_path" : None, 305 | "change_flags" : None, 306 | "req_telemetry" : None, 307 | }) 308 | 309 | if to['type'] > 1 : # repeaters and room servers 310 | completion_list.update({ 311 | "login" : None, 312 | "logout" : None, 313 | "req_status" : None, 314 | "cmd" : None, 315 | "ver" : None, 316 | "advert" : None, 317 | "time" : None, 318 | "clock" : {"sync" : None}, 319 | "reboot" : None, 320 | "start ota" : None, 321 | "password" : None, 322 | "neighbors" : None, 323 | "get" : {"name" : None, 324 | "role":None, 325 | "radio" : None, 326 | "freq":None, 327 | "tx":None, 328 | "af" : None, 329 | "repeat" : None, 330 | "allow.read.only" : None, 331 | "flood.advert.interval" : None, 332 | "flood.max":None, 333 | "advert.interval" : None, 334 | "guest.password" : None, 335 | "rxdelay": None, 336 | "txdelay": None, 337 | "direct.tx_delay":None, 338 | "public.key":None, 339 | "lat" : None, 340 | "lon" : None, 341 | }, 342 | "set" : {"name" : None, 343 | "radio" : {",,,":None, "f,bw,sf,cr": None}, 344 | "freq" : None, 345 | "tx" : None, 346 | "af": None, 347 | "repeat" : {"on": None, "off": None}, 348 | "flood.advert.interval" : None, 349 | "flood.max" : None, 350 | "advert.interval" : None, 351 | "guest.password" : None, 352 | "allow.read.only" : {"on": None, "off": None}, 353 | "rxdelay" : None, 354 | "txdelay": None, 355 | "direct.txdelay" : None, 356 | "lat" : None, 357 | "lon" : None, 358 | }, 359 | "erase": None, 360 | "log" : {"start" : None, "stop" : None, "erase" : None} 361 | }) 362 | 363 | completion_list.update({ 364 | "cli" : None, 365 | "script" : None, 366 | "quit" : None 367 | }) 368 | 369 | return completion_list 370 | make_completion_dict.custom_vars = {} 371 | 372 | async def interactive_loop(mc, to=None) : 373 | print("""Interactive mode, most commands from terminal chat should work. 374 | Use \"to\" to select recipient, use Tab to complete name ... 375 | Line starting with \"$\" or \".\" will issue a meshcli command. 376 | \"quit\", \"q\", CTRL+D will end interactive mode""") 377 | 378 | contact = to 379 | prev_contact = None 380 | 381 | await mc.ensure_contacts() 382 | await subscribe_to_msgs(mc, above=True) 383 | 384 | try: 385 | while True: # purge msgs 386 | res = await mc.commands.get_msg() 387 | if res.type == EventType.NO_MORE_MSGS: 388 | break 389 | 390 | if os.path.isdir(MCCLI_CONFIG_DIR) : 391 | our_history = FileHistory(MCCLI_HISTORY_FILE) 392 | else: 393 | our_history = None 394 | 395 | # beware, mouse support breaks mouse scroll ... 396 | session = PromptSession(history=our_history, 397 | wrap_lines=False, 398 | mouse_support=False, 399 | complete_style=CompleteStyle.MULTI_COLUMN) 400 | 401 | bindings = KeyBindings() 402 | 403 | res = await mc.commands.get_custom_vars() 404 | cv = [] 405 | if res.type != EventType.ERROR : 406 | cv = list(res.payload.keys()) 407 | make_completion_dict.custom_vars = {k:None for k in cv} 408 | 409 | # Add our own key binding. 410 | @bindings.add("escape") 411 | def _(event): 412 | event.app.current_buffer.cancel_completion() 413 | 414 | last_ack = True 415 | while True: 416 | color = process_event_message.color 417 | classic = interactive_loop.classic or not color 418 | print_name = interactive_loop.print_name 419 | 420 | if classic: 421 | prompt = "" 422 | else: 423 | prompt = f"{ANSI_INVERT}" 424 | 425 | # some possible symbols for prompts 🭬🬛🬗🭬🬛🬃🬗🭬🬛🬃🬗🬏🭀🭋🭨🮋 426 | if print_name or contact is None : 427 | prompt = prompt + f"{ANSI_BGRAY}" 428 | prompt = prompt + f"{mc.self_info['name']}" 429 | if classic : 430 | prompt = prompt + " > " 431 | else : 432 | prompt = prompt + "🭨" 433 | 434 | if not contact is None : 435 | if not last_ack: 436 | prompt = prompt + f"{ANSI_BRED}" 437 | if classic : 438 | prompt = prompt + "!" 439 | elif contact["type"] == 3 : # room server 440 | prompt = prompt + f"{ANSI_BCYAN}" 441 | elif contact["type"] == 2 : 442 | prompt = prompt + f"{ANSI_BMAGENTA}" 443 | elif contact["type"] == 0 : # public channel 444 | prompt = prompt + f"{ANSI_BGREEN}" 445 | else : 446 | prompt = prompt + f"{ANSI_BBLUE}" 447 | if not classic: 448 | prompt = prompt + f"{ANSI_INVERT}" 449 | 450 | if print_name and not classic : 451 | prompt = prompt + "🭬" 452 | 453 | prompt = prompt + f"{contact['adv_name']}" 454 | if classic : 455 | prompt = prompt + f"{ANSI_NORMAL} > " 456 | else: 457 | prompt = prompt + f"{ANSI_NORMAL}🭬" 458 | 459 | prompt = prompt + f"{ANSI_END}" 460 | 461 | if not color : 462 | prompt=escape_ansi(prompt) 463 | 464 | session.app.ttimeoutlen = 0.2 465 | session.app.timeoutlen = 0.2 466 | 467 | completer = NestedCompleter.from_nested_dict( 468 | make_completion_dict(mc.contacts, to=contact)) 469 | 470 | line = await session.prompt_async(ANSI(prompt), 471 | complete_while_typing=False, 472 | completer=completer, 473 | key_bindings=bindings) 474 | 475 | if line == "" : # blank line 476 | pass 477 | 478 | # raw meshcli command as on command line 479 | elif line.startswith("$") : 480 | args = shlex.split(line[1:]) 481 | await process_cmds(mc, args) 482 | 483 | elif line.startswith("@") : # send a cli command that won't need quotes ! 484 | args=["cli", line[1:]] 485 | await process_cmds(mc, args) 486 | 487 | elif line.startswith("to ") : # dest 488 | dest = line[3:] 489 | if dest.startswith("\"") or dest.startswith("\'") : # if name starts with a quote 490 | dest = shlex.split(dest)[0] # use shlex.split to get contact name between quotes 491 | nc = mc.get_contact_by_name(dest) 492 | if nc is None: 493 | if dest == "public" : 494 | nc = {"adv_name" : "public", "type" : 0, "chan_nb" : 0} 495 | elif dest.startswith("ch"): 496 | dest = int(dest[2:]) 497 | nc = {"adv_name" : "chan" + str(dest), "type" : 0, "chan_nb" : dest} 498 | elif dest == ".." : # previous recipient 499 | nc = prev_contact 500 | elif dest == "~" or dest == "/" or dest == mc.self_info['name']: 501 | nc = None 502 | elif dest == "!" : 503 | nc = process_event_message.last_node 504 | else : 505 | print(f"Contact '{dest}' not found in contacts.") 506 | nc = contact 507 | if nc != contact : 508 | last_ack = True 509 | prev_contact = contact 510 | contact = nc 511 | 512 | elif line == "to" : 513 | if contact is None : 514 | print(mc.self_info['name']) 515 | else: 516 | print(contact["adv_name"]) 517 | 518 | elif line == "quit" or line == "q" : 519 | break 520 | 521 | # commands that take one parameter (don't need quotes) 522 | elif line.startswith("public ") or line.startswith("cli ") : 523 | cmds = line.split(" ", 1) 524 | args = [cmds[0], cmds[1]] 525 | await process_cmds(mc, args) 526 | 527 | # lines starting with ! are sent as reply to last received msg 528 | elif line.startswith("!"): 529 | ln = process_event_message.last_node 530 | if ln is None : 531 | print("No received msg yet !") 532 | elif ln["type"] == 0 : 533 | await process_cmds(mc, ["chan", str(contact["chan_nb"]), line] ) 534 | else : 535 | last_ack = await msg_ack(mc, ln, line[1:]) 536 | if last_ack == False : 537 | contact = ln 538 | 539 | # commands are passed through if at root 540 | elif contact is None or line.startswith(".") : 541 | args = shlex.split(line) 542 | await process_cmds(mc, args) 543 | 544 | # commands that take contact as second arg will be sent to recipient 545 | elif contact["type"] > 0 and (line == "sc" or line == "share_contact" or\ 546 | line == "ec" or line == "export_contact" or\ 547 | line == "uc" or line == "upload_contact" or\ 548 | line == "rp" or line == "reset_path" or\ 549 | line == "contact_info" or line == "ci" or\ 550 | line == "req_status" or line == "rs" or\ 551 | line == "req_telemetry" or line == "rt" or\ 552 | line == "path" or\ 553 | line == "logout" ) : 554 | args = [line, contact['adv_name']] 555 | await process_cmds(mc, args) 556 | 557 | # same but for commands with a parameter 558 | elif contact["type"] > 0 and (line.startswith("cmd ") or\ 559 | line.startswith("cp ") or line.startswith("change_path ") or\ 560 | line.startswith("cf ") or line.startswith("change_flags ") or\ 561 | line.startswith("login ")) : 562 | cmds = line.split(" ", 1) 563 | args = [cmds[0], contact['adv_name'], cmds[1]] 564 | await process_cmds(mc, args) 565 | 566 | elif line.startswith(":") : # : will send a command to current recipient 567 | args=["cmd", contact['adv_name'], line[1:]] 568 | await process_cmds(mc, args) 569 | 570 | elif line == "reset path" : # reset path for compat with terminal chat 571 | args = ["reset_path", contact['adv_name']] 572 | await process_cmds(mc, args) 573 | 574 | elif line == "list" : # list command from chat displays contacts on a line 575 | it = iter(mc.contacts.items()) 576 | first = True 577 | for c in it : 578 | if not first: 579 | print(", ", end="") 580 | first = False 581 | print(f"{c[1]['adv_name']}", end="") 582 | print("") 583 | 584 | elif line.startswith("send") or line.startswith("\"") : 585 | if line.startswith("send") : 586 | line = line[5:] 587 | if line.startswith("\"") : 588 | line = line[1:] 589 | last_ack = await msg_ack(mc, contact, line) 590 | 591 | elif contact["type"] == 0 : # channel, send msg to channel 592 | await process_cmds(mc, ["chan", str(contact["chan_nb"]), line] ) 593 | 594 | elif contact["type"] == 1 : # chat, send to recipient and wait ack 595 | last_ack = await msg_ack(mc, contact, line) 596 | 597 | elif contact["type"] == 2 or contact["type"] == 3 : # repeater, send cmd 598 | await process_cmds(mc, ["cmd", contact["adv_name"], line]) 599 | 600 | except (EOFError, KeyboardInterrupt): 601 | print("Exiting cli") 602 | except asyncio.CancelledError: 603 | # Handle task cancellation from KeyboardInterrupt in asyncio.run() 604 | print("Exiting cli") 605 | interactive_loop.classic = False 606 | interactive_loop.print_name = True 607 | 608 | async def msg_ack (mc, contact, msg) : 609 | result = await mc.commands.send_msg(contact, msg) 610 | if result.type == EventType.ERROR: 611 | print(f"⚠️ Failed to send message: {result.payload}") 612 | return False 613 | 614 | exp_ack = result.payload["expected_ack"].hex() 615 | res = await mc.wait_for_event(EventType.ACK, attribute_filters={"code": exp_ack}, timeout=5) 616 | if res is None : 617 | return False 618 | 619 | return True 620 | 621 | async def next_cmd(mc, cmds, json_output=False): 622 | """ process next command """ 623 | try : 624 | argnum = 0 625 | if cmds[0].startswith(".") : # override json_output 626 | json_output = True 627 | cmd = cmds[0][1:] 628 | else: 629 | cmd = cmds[0] 630 | match cmd : 631 | case "help" : 632 | command_help() 633 | 634 | case "ver" | "query" | "v" | "q": 635 | res = await mc.commands.send_device_query() 636 | logger.debug(res) 637 | if res.type == EventType.ERROR : 638 | print(f"ERROR: {res}") 639 | elif json_output : 640 | print(json.dumps(res.payload, indent=4)) 641 | else : 642 | print("Devince info :") 643 | if res.payload["fw ver"] >= 3: 644 | print(f" Model: {res.payload['model']}") 645 | print(f" Version: {res.payload['ver']}") 646 | print(f" Build date: {res.payload['fw_build']}") 647 | else : 648 | print(f" Firmware version : {res.payload['fw ver']}") 649 | 650 | case "clock" : 651 | if len(cmds) > 1 and cmds[1] == "sync" : 652 | argnum=1 653 | res = await mc.commands.set_time(int(time.time())) 654 | logger.debug(res) 655 | if res.type == EventType.ERROR: 656 | if json_output : 657 | print(json.dumps({"error" : "Error syncing time"})) 658 | else: 659 | print(f"Error setting time: {res}") 660 | elif json_output : 661 | res.payload["ok"] = "time synced" 662 | print(json.dumps(res.payload, indent=4)) 663 | else : 664 | print("Time synced") 665 | else: 666 | res = await mc.commands.get_time() 667 | timestamp = res.payload["time"] 668 | if res.type == EventType.ERROR: 669 | print(f"Error getting time: {res}") 670 | elif json_output : 671 | print(json.dumps(res.payload, indent=4)) 672 | else : 673 | print('Current time :' 674 | f' {datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")}' 675 | f' ({timestamp})') 676 | 677 | case "sync_time"|"clock sync"|"st": # keep if for the st shortcut 678 | res = await mc.commands.set_time(int(time.time())) 679 | logger.debug(res) 680 | if res.type == EventType.ERROR: 681 | if json_output : 682 | print(json.dumps({"error" : "Error syncing time"})) 683 | else: 684 | print(f"Error syncing time: {res}") 685 | elif json_output : 686 | res.payload["ok"] = "time synced" 687 | print(json.dumps(res.payload, indent=4)) 688 | else: 689 | print("Time synced") 690 | 691 | case "time" : 692 | argnum = 1 693 | res = await mc.commands.set_time(cmds[1]) 694 | logger.debug(res) 695 | if res.type == EventType.ERROR: 696 | if json_output : 697 | print(json.dumps({"error" : "Error setting time"})) 698 | else: 699 | print (f"Error setting time: {res}") 700 | elif json_output : 701 | print(json.dumps(res.payload, indent=4)) 702 | else: 703 | print("Time set") 704 | 705 | case "set": 706 | argnum = 2 707 | match cmds[1]: 708 | case "help" : 709 | argnum = 1 710 | print("""Available parameters : 711 | pin