diff --git a/variants/linux/README.md b/variants/linux/README.md index 7d854a6523..f22efd65c0 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -72,12 +72,16 @@ about a minute. ## Setup -### 1. Install the binary +### 1. Install the binaries ```sh sudo install -m 755 .pio/build/linux_repeater/meshcored /usr/bin/meshcored +sudo install -m 755 variants/linux/meshcorectl /usr/bin/meshcorectl ``` +`meshcorectl` is a CLI client that needs nothing installed (see +[The control CLI](#the-control-cli)); any serial tool works in its place. + ### 2. Create the config file Two ready-made templates are provided in `variants/linux/`: @@ -249,11 +253,12 @@ sudo journalctl -u meshcored -f `meshcored` exposes a local CLI console, kept separate from the logs (which go to stdout / journald). Under the systemd unit it is `/run/meshcored/console`. -Connect with [`meshcore-cli`](https://github.com/fdlamotte/meshcore-cli), or any -serial terminal (see [The control CLI](#the-control-cli)): +Connect with `meshcorectl` (installed in [§1](#1-install-the-binaries)) or with +[`meshcore-cli`](https://github.com/fdlamotte/meshcore-cli): ```sh -sudo meshcore-cli -r -s /run/meshcored/console +sudo meshcorectl # REPL +sudo meshcore-cli -r -s /run/meshcored/console # the same CLI, via meshcore-cli ``` ``` @@ -324,7 +329,29 @@ that is not a symlink sits there; a symlink left by a crashed daemon is reclaimed. `reboot` unpublishes the console before re-executing, so the new process starts from a clean path. -Because the console is a terminal device, any serial tool can attach: +Three ways to drive it with `meshcorectl`: + +```sh +sudo meshcorectl # REPL: line editing, history, Tab completion +sudo meshcorectl set name my-repeater # one-shot: send, print reply, exit +printf 'ver\nneighbors\n' | sudo meshcorectl # piped: one command per line +sudo meshcorectl -s /tmp/meshcore-1000/console ver # a console at another path +``` + +The REPL needs no `socat` or `rlwrap`: arrow-key editing, Ctrl-R search, Tab +completion of known commands, and history in `~/.meshcorectl_history`. +`MESHCORED_CONSOLE` names the path for the client, as `-s` does; otherwise it +tries the same order as the daemon and takes the first that exists. + +`meshcorectl` exits `0` only if every command it sent was actually run by the +daemon — that includes `reboot`, `clkreboot` and `poweroff`, whose only "reply" +is their own echo before the daemon goes away. It exits `1` if the console is +missing or unusable, if a command draws no reply at all, or if a *later* command +in a piped script finds the daemon already gone (the case after one of those +three ran earlier in the same script). A piped script stops at the first such +failure rather than sending the remaining lines into a dead console. + +Because the console is a terminal device, any serial tool can attach instead: `meshcore-cli -r -s `, `screen`, `minicom`, `picocom`. There is no single-client rule: two tools attached at once share one CLI and see each other's traffic. diff --git a/variants/linux/meshcorectl b/variants/linux/meshcorectl new file mode 100755 index 0000000000..00ecd74913 --- /dev/null +++ b/variants/linux/meshcorectl @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""meshcorectl - talk to a running meshcored over its console. + + meshcorectl interactive REPL (line editing, history, tab-complete) + meshcorectl set name foo one-shot: send one command, print the reply, exit + echo -e "advert\\nneighbors" | meshcorectl pipe: run each line, print replies + meshcorectl -s PATH [...] use the console at PATH + +The interactive mode is a self-contained readline client (equivalent to +attaching a serial terminal, with rlwrap on top): arrow-key editing, Ctrl-R +reverse search, persistent history in ~/.meshcorectl_history, and Tab +completion of known commands. Nothing beyond the Python standard library. + +Console path: -s PATH, else $MESHCORED_CONSOLE, else the first of +/run/meshcored/console, $XDG_RUNTIME_DIR/meshcore/console and +/tmp/meshcore-/console that exists -- the order meshcored publishes in. +Access: the console is owner-only, so run as the daemon's user or as root. + +Exit status is 0 only if every command was actually run by the daemon. It is 1 +if the console is missing or unusable, if a command draws no reply at all, or +if the daemon closes the console mid-script (which is what `reboot` and friends +do). Remaining lines of a piped script are not attempted after any of those. +""" +import os +import sys +import time +import select +import termios +import tty +import atexit + +HISTFILE = os.path.expanduser("~/.meshcorectl_history") + + +def candidates(): + """Where a console may be, in the order meshcored publishes.""" + paths = ["/run/meshcored/console"] + xdg = os.environ.get("XDG_RUNTIME_DIR") + if xdg: + paths.append(os.path.join(xdg, "meshcore", "console")) + paths.append("/tmp/meshcore-%d/console" % os.getuid()) + return paths + + +def default_console(): + env = os.environ.get("MESHCORED_CONSOLE") + if env: + return env + for path in candidates(): + if os.path.exists(path): # follows the symlink, so a stale one does not count + return path + return None + + +CONSOLE = default_console() + +# Top-level commands, in the order the firmware dispatches them: +# examples/simple_repeater/MyMesh.cpp handleCommand() takes the first three, +# then falls through to src/helpers/CommonCLI.cpp handleCommand() for the rest. +# Anything not matched there comes back as "Unknown command". +# +# Entries ending in a bare word still take arguments -- "neighbor.remove", +# "password", "sensor get/set", "setperm", "tempradio" and "time" all match on a +# trailing space in the firmware, so "time" alone is an unknown command. Use +# "time " to set the clock and "clock" to read it back. +# +# "region load" is listed for completeness but is not usable from here: it puts +# the firmware into a multi-line mode (CommonCLI.cpp region_load_active) that +# reads indented region names and ends on a blank line, and both loops below +# strip indentation and skip blank lines. Use "region put"/"region def" instead. +# +# "clock sync" is listed because it exists, but it only works over the mesh: it +# needs the sender's timestamp, and the console always passes 0 (see the +# handleCommand(0, ...) calls in examples/simple_repeater/main.cpp), so it +# replies "ERR: clock cannot go backwards" every time. Use "time" instead. +TOP = [ + "advert", "advert.zerohop", "board", "clear stats", "clkreboot", "clock", + "clock sync", "discover.neighbors", "erase", "get", "gps", "gps advert", + "gps interval", "gps off", "gps on", "gps setloc", "gps sync", + "log", "log erase", "log start", "log stop", + "neighbors", "neighbor.remove", "password", "poweroff", "powersaving", + "powersaving off", "powersaving on", "reboot", + "region", "region allowf", "region def", "region default", "region denyf", + "region get", "region home", "region list allowed", "region list denied", + "region load", "region put", "region remove", "region save", + "sensor get", "sensor list", "sensor set", "set", "setperm", "shutdown", + "start ota", "stats-core", "stats-packets", "stats-radio", "tempradio", + "time", "ver", +] + +# Keys usable after "set " (CommonCLI.cpp handleSetCmd). +# +# The bridge.* setters are absent on purpose: every one of them sits behind +# WITH_BRIDGE / WITH_RS232_BRIDGE / WITH_ESPNOW_BRIDGE (CommonCLI.cpp:720-768), +# and no linux env defines any of the three, so the firmware this talks to +# answers "unknown config: bridge.enabled ...". ("bridge.type" is a getter and +# is not guarded, so it stays in GETONLY below.) +SETKEYS = [ + "adc.multiplier", "advert.interval", "af", "agc.reset.interval", + "allow.read.only", "cad", + "direct.txdelay", "dutycycle", "flood.advert.interval", "flood.max", + "flood.max.advert", "flood.max.unscoped", "freq", "guest.password", + "int.thresh", "lat", "lon", "loop.detect", "multi.acks", "name", + "owner.info", "path.hash.mode", "prv.key", "radio", "radio.fem.rxgain", + "radio.rxgain", "repeat", "rxdelay", "tx", "txdelay", +] + +# "get" accepts everything "set" does, plus these read-only keys +# (CommonCLI.cpp handleGetCmd, and "get acl" from MyMesh.cpp). +GETONLY = [ + "acl", "bootloader.ver", "bridge.type", "public.key", "pwrmgt.bootmv", + "pwrmgt.bootreason", "pwrmgt.source", "pwrmgt.support", "role", +] +GETKEYS = sorted(SETKEYS + GETONLY) + + +def attach(): + if CONSOLE is None: + sys.exit("meshcorectl: no console found at any of\n %s\n" + " is meshcored running? name one with -s PATH or MESHCORED_CONSOLE" + % "\n ".join(candidates())) + try: + fd = os.open(CONSOLE, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + except FileNotFoundError: + sys.exit("meshcorectl: no console at %s\n" + " is meshcored running? name another with -s PATH or MESHCORED_CONSOLE" + % CONSOLE) + except PermissionError: + sys.exit("meshcorectl: cannot open %s: permission denied\n" + " the console is owner-only: run as the daemon's user, or as root" + % CONSOLE) + except OSError as e: + sys.exit("meshcorectl: cannot open %s: %s" % (CONSOLE, e)) + if not os.isatty(fd): + sys.exit("meshcorectl: %s is not a console device" % CONSOLE) + # Raw: the daemon echoes for us, and every byte must pass unchanged in both + # directions. Then drop anything a previous session left unread, so the + # first reply we see is to a command we sent. + tty.setraw(fd) + termios.tcflush(fd, termios.TCIFLUSH) + return fd + + +class Refused(Exception): + """A command the daemon did not run. The message says how we know.""" + + +def read_some(fd): + """One read, or b"" once the daemon has closed its end of the console. + + A hung-up console reads as EIO (or end of file) rather than blocking; that + is the only way the daemon's departure shows up here. + """ + try: + return os.read(fd, 4096) + except BlockingIOError: + return None + except OSError: + return b"" + + +def send_command(fd, line, idle=0.4, first=2.0, total=6.0): + """Send one command and return the daemon's reply, or raise Refused. + + The daemon echoes a command character by character as it consumes it and + only then prints the reply (console->print(c) in the repeater's loop()), so + the echo doubles as proof that the command was actually read. + """ + try: + os.write(fd, (line + "\r").encode()) + except OSError: + # `reboot`, `poweroff` and `shutdown` take the daemon down without + # replying, so a script that continues past one finds the console + # hung up. + raise Refused("meshcored closed the console before '%s' could be " + "sent\n an earlier command most likely rebooted or shut " + "it down" % line) + + buf = b"" + deadline = time.monotonic() + total + while True: + # The first byte is worth waiting longer for than the rest: the reply + # follows its echo back to back, but the echo itself waits on the + # daemon's next loop iteration, and an empty answer is an error now + # rather than a blank line. + r, _, _ = select.select([fd], [], [], idle if buf else first) + if not r: # idle gap -> reply is complete + break + chunk = read_some(fd) + if chunk == b"": # daemon closed the console + break + if chunk: + buf += chunk + if time.monotonic() > deadline: + break + + text = buf.decode(errors="replace").replace("\r", "") + echo = line + "\n" + if not text.startswith(echo): + raise Refused(text.strip("\n") or + "no reply from meshcored at %s for '%s'\n the console " + "opened but the command was never read; check that " + "meshcored is still running" % (CONSOLE, line)) + return text[len(echo):].strip("\n") + + +def make_completer(): + """Complete against the whole line, not just the last word. + + interactive() clears readline's delimiter set, so `text` is everything typed + so far. Matching on the full line is what makes the multi-word entries usable + -- "region li" only reaches "region list allowed" if the completer can see + both words. + """ + def completer(text, state): + stripped = text.lstrip() + indent = text[:len(text) - len(stripped)] + for prefix, pool in (("set ", SETKEYS), ("get ", GETKEYS)): + if stripped.startswith(prefix): + arg = stripped[len(prefix):] + matches = [prefix + k for k in pool if k.startswith(arg)] + break + else: + matches = [c for c in TOP if c.startswith(stripped)] + matches = sorted(set(matches)) + return indent + matches[state] if state < len(matches) else None + return completer + + +def report(refusal): + """Print why a command did not run, and give the exit status.""" + sys.stderr.write("meshcorectl: %s\n" % refusal) + return 1 + + +def interactive(): + import readline + try: + readline.read_history_file(HISTFILE) + except OSError: + pass + atexit.register(lambda: _save_history(readline)) + readline.set_history_length(1000) + readline.set_completer_delims("") + readline.set_completer(make_completer()) + readline.parse_and_bind("tab: complete") + + fd = attach() + print("meshcorectl -> %s (Tab completes, Ctrl-D quits)" % CONSOLE) + status = 0 + while True: + try: + line = input("meshcore> ").strip() + except EOFError: + print() + break + except KeyboardInterrupt: + print() + continue + if not line: + continue + if line in ("quit", "exit"): + break + try: + out = send_command(fd, line) + except Refused as refusal: + status = report(refusal) + break + if out: + print(out) + os.close(fd) + return status + + +def _save_history(readline): + try: + readline.write_history_file(HISTFILE) + except OSError: + pass + + +def run_lines(lines): + """Run each line over one console session. Returns the process exit status.""" + fd = attach() + status = 0 + for raw in lines: + cmd = raw.strip() + if not cmd: + continue + try: + out = send_command(fd, cmd) + except Refused as refusal: + # Nothing ran, and every cause of that also leaves the console + # useless for the rest of the script. + status = report(refusal) + break + if out: + print(out) + os.close(fd) + return status + + +def main(): + global CONSOLE + args = sys.argv[1:] + if args and args[0] in ("-h", "--help"): + print(__doc__.strip()) + return 0 + if args and args[0] in ("-s", "--console"): + if len(args) < 2: + sys.exit("meshcorectl: %s needs a path" % args[0]) + CONSOLE = args[1] + args = args[2:] + if args: + return run_lines([" ".join(args)]) # one-shot + if not sys.stdin.isatty(): + return run_lines(sys.stdin) # piped script + return interactive() + + +if __name__ == "__main__": + sys.exit(main())