Meshtastic live logger with an interactive command prompt
Project description
mushtastic
Meshtastic live logger with an interactive command prompt.
mushtastic (installed as the mush console command) connects to a
Meshtastic radio (over USB/serial, TCP, or BLE), archives every packet
it sees into a local SQLite database, and drops you into an
interactive REPL where you can inspect nodes, browse logged packets,
send messages, manage channels, and traceroute other nodes while the
logger keeps running in the background.
Features
- Live packet logging to SQLite (
peeweeORM) - nothing is lost between runs. - Interactive REPL (built directly on
prompt-toolkit) with command history,/commandautocompletion, and a live status toolbar. - Chat-style messaging: select a node or channel as the current target
with
/target, then anything typed without a leading/is sent to it as a text message. Prefix a single line with@ENTITYto send just that message elsewhere without changing the current target. - Node database: track short/long names, hardware info, position, favorites, device/environment telemetry, and last-heard times.
- Packet database: filter, replay/reprocess, import, and inspect raw packets already logged.
- Channel management: list, add, remove, and move channels directly from the REPL.
- Send text messages, traceroutes, and position requests to nodes by short name or node ID.
Requirements
- Python >= 3.10 (the checked-in
.envrcusespyenvwith Python 3.13.5 viadirenv). - A Meshtastic-capable radio, reachable via USB serial, TCP (e.g. Wi-Fi/ESP32 nodes), or BLE.
Install
pip install -e .
This installs the mush console script (see [project.scripts] in
pyproject.toml) backed by the mushtastic package in src/mushtastic.
If you use direnv, entering the project directory will automatically
activate the pyenv-managed virtualenv defined in .envrc.
Usage
Show top-level help:
mush --help
Global options (before the subcommand)
| Option | Description |
|---|---|
-d, --debug |
Enable device debug logs from startup. |
--database PATH |
Explicit path to the SQLite database file. |
--pos-fmt FORMAT |
Position display format: latlon (default), google, or osm link. |
Without --database, the DB defaults to mush.db in the current working directory.
Starting a live session
mush live # auto-detect a USB-connected radio
mush live --dev /dev/ttyUSB0
mush live --tcp 192.168.1.50:4403
mush live --ble AA:BB:CC:DD:EE:FF
Only one of --ble, --tcp, --dev may be given; without any of them
mush tries to auto-connect over serial. Once connected, mush drops
into a REPL (prompt shows ๐ฐ<hops> <shortname>@<target>>, where
<hops> is the radio's currently configured default hop limit - see
/hops - <target> is colored magenta for a channel or yellow for a
node) and starts archiving every received packet to the database.
Press Ctrl+D to exit the REPL (closes the radio interface cleanly).
Inside the REPL, commands start with / (e.g. /node ls); typing /
and pressing Tab shows a completion menu of available commands. A line
typed without a leading / is sent as a text message to the
currently selected target instead of being treated as a command -
select a target (a node or a channel) with /target:
/target offers a popup completion menu for matching known channels and
nodes. Type the beginning of a channel name, node short name, channel
index, or node ID and press Tab to select it.
you@primary> /target !deadbeef # target a node by ID
you@primary> /target JOHN # target a node by short name
you@primary> /target #1 # target channel 1
you@primary> /target admin # target a channel by name
you@JOHN> Hey, are you there? # sent to JOHN, no leading '/' needed
If ENTITY (the argument to /target) starts with ! it's treated as a
node ID; if it starts with # it's a channel number (0-7); otherwise
it's looked up by name - a channel name takes priority over a node
short name if both match. Without ever running /target, the target
defaults to the primary channel, matching the previous behavior.
To send a single one-off message somewhere else without changing the
current /target target, start the line with @ENTITY instead (ENTITY
is resolved exactly like the /target argument):
you@JOHN> @LongFast hello everyone! # sent to the "LongFast" channel
you@JOHN> @!deadbeef who are you? # sent to a node by ID
you@JOHN> @#1 need assistance # sent to channel 1
you@JOHN> @admin sup bro! # sent to a channel or node by name
you@JOHN> Still talking to JOHN here # /target (JOHN) is unaffected
Either message form (a bare line, or one starting with @ENTITY) can
override the hop count for that single message with #N (N from 0 to
7), and it doesn't matter whether #N comes before or after @ENTITY:
you@JOHN> #3 hello there # sent to JOHN with hop limit 3
you@JOHN> @admin #2 need help # sent to "admin" with hop limit 2
you@JOHN> #2 @admin need help # same as above - order doesn't matter
Without a #N prefix, messages use the radio's own configured hop
limit, which can be read or changed with /hops:
you@primary> /hops # print the radio's currently configured hop limit
you@primary> /hops 3 # set it to 3
With --ble, the underlying meshtastic library can occasionally
deadlock while disconnecting
(meshtastic/python#792,
still open as of meshtastic 2.7.10). mush bounds this with a
5-second timeout, so a Ctrl+D exit will print a warning and continue
exiting instead of hanging forever if that happens.
Command reference
The shell CLI and interactive REPL have separate command trees. The shell
tree starts sessions and queries local data; radio operations are available
after mush live in the REPL. Use mush <command> --help or
/<command> --help for the authoritative syntax.
REPL commands
Inside live, prefix commands with /.
help- print Click's--helpfor the whole app inside the REPL.debug(d) - toggle verbose device debug logging.target <ENTITY>- select a node or channel as the current target (see above); subsequent lines typed without a leading/are sent to it. (Not a/-prefixed line, but also worth noting here: a line starting with@ENTITYsends just that one message elsewhere without changing the current target, and either message form can start its message text with#Nto override the hop count for that message - see above.)hops [COUNT]- get (no argument) or set (COUNT, 0-7) the radio's default hop limit, used for any message that doesn't override it with a#Nprefix.trace <NODE_ID|SHORT_NAME> [HOP_LIMIT](t,traceroute) - send a traceroute request; doesn't block, the response (if any) is logged like any other received packet.location <NODE_ID|SHORT_NAME>(loc) (REPL only) - request the current position from a node; doesn't block, the response (if any) is logged like any other received packet, same as with/trace- since the response is only useful to see live, there's no shell equivalent.conf(config) - inspect and edit writable radio configuration:conf list,conf get OPTION,conf set OPTION VALUE, andconf apply.setvalidates and stages changes locally;applywrites all staged changes to the radio. Repeated values must be JSON arrays, such as[1, 2].node(n,nodes) - manage the known-nodes table:node(no args) - show info about our own node.node ls(l,list) - list all known nodes.node show <NAME>(s,i,info) - show details for one node.node rm <ID>- delete a node from the local DB.node fav <NAME>- toggle favorite status (synced to the device).node dls- dump the radio's live in-memory NodeDB as JSON.node pull- copy the radio's live NodeDB into the local database.node count(c) - print the total number of nodes in the local DB.
channel(c,chan) - manage Meshtastic channels:channel/channel ls(l,list) - list channels.channel add <INDEX> <NAME> [PSK] [--random-psk]- add/overwrite a channel;--random-pskgenerates and displays a random 32-byte PSK. (PSK defaults to the default channel key; usebase64:...for a custom key).channel rm <INDEX>- delete a channel (not the primary one).channel mv <INDEX1> <INDEX2>- exchange two secondary channel slots.
filtercontrols which packets are shown live and returned by REPL packet commands.filter-outhides matching packets. Each providesclear,from [NODE...],to [NODE...],channel [CHANNEL...], andportnum [PORTNUM...]. No values on a field command clears that field and prints its prior value. Within a field, values are alternatives; across fields, inclusion filters are combined. An exclusion is applied only when every active exclusion field matches. Filters never stop packet persistence.packet(p,pkt,packets) - query the local packet log:packet ls [N](l,list) - print the filtered packets (in the REPL,Ndefaults to 100 if omitted).packet show [ID] [--raw](s) - show one packet in detail (or the filtered set);--rawprints the raw JSON blob.packet process- reprocess the filtered packets (re-derive node state such as last-heard, telemetry, position from stored packets).packet reprocess- reprocess every stored packet from its raw data, ignoring active filters, with a progress bar.packet import [--merge=skip|overwrite|append]- read a JSON packet blob from stdin and insert or merge it into the database.packet portnums(p) - show a count of stored packets per portnum.packet count(c) - print the count of packets in the filtered set.
Shell commands
mush ble-scandiscovers nearby Meshtastic BLE devices without connecting.mush node ls [LIMIT],show NAME,rm ID, andcountquery the local known-node database.mush packetaccepts one-shot filters such as--portnum,--from-id,--to-id, packet-ID ranges, and receive-time ranges. Itslist,show,process,import,portnums, andcountsubcommands operate on that result. For example:mush packet --portnum POSITION_APP list 20.
Example:
mush live --dev /dev/ttyUSB0
you@primary> /node ls
you@primary> /target BASE
you@BASE> Hello from the field!
you@BASE> /filter portnum POSITION_APP
you@BASE> /packet ls 20
you@BASE> /target #1
you@admin> /help
Data storage
- SQLite database:
./mush.dbin the current working directory by default, or the path passed to--database. Contains three tables:node,packet, andreplhistory. - REPL command history: timestamped rows in the active SQLite database.
- Startup creates missing tables and adds the
packet.channelcolumn to databases created before channel logging was added.
The database is ignored by git (see .gitignore, patterns *.mush and
*.db), so it is local/runtime state, not project files. It can contain
node identities, packet contents, telemetry, and locations. Treat it as
sensitive data, and copy the SQLite file while mush is not running if you
need a backup or wish to move the history to another machine.
Development
Project layout
mushtastic/
โโโ pyproject.toml # packaging metadata, deps, entry point (mush = mushtastic.app:cli)
โโโ .envrc # direnv/pyenv Python version pin
โโโ src/
โโโ mushtastic/
โโโ __init__.py # re-exports `cli` from app.py
โโโ __main__.py # allows `python -m mushtastic`
โโโ app.py # assembles the full app (imports cli.py + commands/ + repl_commands/)
โโโ cli.py # shell root Click group + top-level commands (live, ble-scan, help)
โโโ commands/ # SHELL (`mush <command>`) command groups
โ โโโ __init__.py # imports node/packet for their registration side-effect
โ โโโ node.py # `node` command group
โ โโโ packet.py # `packet` command group
โโโ repl_root.py # root click group for the REPL-only ('/command') tree
โโโ repl_commands/ # REPL (`/command`) command groups - independent of commands/
โ โโโ __init__.py # imports top/node/channel/packet for their registration side-effect
โ โโโ top.py # REPL versions of help/debug/trace/location/target/hops/conf (no REPL /msg)
โ โโโ node.py # REPL `node` command group
โ โโโ channel.py # REPL `channel` command group
โ โโโ packet.py # REPL `packet` command group
โโโ services/ # shared business logic (no Click deps) used by both trees
โ โโโ __init__.py
โ โโโ messaging.py # trace/location/target/hops/conf/debug + entity resolution/send helpers (used by shell msg + @ENTITY & current-target sends)
โ โโโ node.py # node group logic
โ โโโ channel.py # channel group logic
โ โโโ packet.py # packet group logic
โโโ models.py # Peewee models: ReplHistory, Node, Packet (+ Packet.process())
โโโ history.py # prompt-toolkit history backed by ReplHistory
โโโ radio.py # channel/target helpers, we_are_live()/wait_for_config(), pubsub callbacks
โโโ repl.py # custom REPL loop + /command dispatch (replaces click-repl)
โโโ prompt.py # REPL prompt message + bottom toolbar
โโโ decorators.py # @requires_radio
โโโ state.py # State singleton (iface, debug flag, ...) + the peewee db Proxy
โโโ formatting.py # pprint() + pure formatting helpers (dates, positions, telemetry, ...)
โโโ colors.py # `clr` - ANSI color/style constants
The application used to live entirely in one ~1400-line app.py; it has
since been split by responsibility as above, and then split again into
two independent Click command trees (shell vs. REPL) sharing one
business-logic layer. Module dependencies flow one way, with no
cycles:
colors.py, state.py, formatting.py (no internal deps)
โ
models.py (state, formatting, colors)
โ
radio.py (+ models, formatting, state)
โ
decorators.py, prompt.py (+ radio, formatting, state, models)
โ
services/*.py (+ decorators, radio, models, state,
formatting, colors - no Click, no
knowledge of either command tree)
โ โ
cli.py repl_root.py + repl.py (each independently: + services,
โ โ colors, formatting, prompt)
commands/*.py repl_commands/*.py (+ their own root group, to attach
(+ cli.py) (+ repl_root.py) subgroups; + services/*.py)
โ โ
app.py (imports cli.py + commands/ + repl_commands/, re-exports `cli`)
Key pieces:
state.py- a singleStateinstance (imported asfrom .state import state) holds all cross-command mutable runtime state: the active Meshtastic interface (state.iface), our own node ID (state.our_node_id), the debug flag, the current/targettarget (target_kind,selected_channel_index,target_node_id), the last-seen telemetry for our own node, and the position display format. Thedb = peewee.Proxy()handle used by every model also lives here. Read/write it asstate.<attr>instead of module-level globals.models.py- thepeeweemodels:ReplHistory- timestamped commands entered in the interactive REPL.Node- one row per known Meshtastic node (by numeric node ID). Has.find(name_or_id)(resolves!hex,^local,^all, or a unique short name) and a rich__str__used bynode ls/node show.Packet- one row per logged packet, storing the raw JSON blob plus a few indexed columns (msg_id,rx_time,from_id,to_id,portnum) for fast filtering..process()parses the blob, updates the relatedNode(last-heard, telemetry, position, node-info), and is what both live packet reception andpacket process/packet importcall into.
radio.py- channel introspection/management helpers (get_channel_node,get_current_channel_index,parse_psk_value,find_channel_by_name, etc.), connection state (we_are_live(),wait_for_config()), the current/targettarget (set_target_channel,set_target_node), and thepubsubcallbacks (on_receive,on_log) that turn incoming Meshtastic packets intoPacketrows via.process(log=True).decorators.py- the@requires_radiodecorator, which guards commands that need a live radio connection (checked viawe_are_live()).prompt.py- the REPL prompt (PromptMessage, shows!<node-id> <shortname> @ <target>>; local and node targets use the Android-derived node-ID color, channel targets use bold bright white, and@and>are gray) and bottom-toolbar (toolbar()) helpers.repl.py- the interactive loop and/commanddispatcher that replacedclick-repl:run_repl()builds oneclick.Contextforrepl_rootper REPL session, reads lines via aprompt_toolkitPromptSession, routes lines starting with/throughdispatch_command()(which resolves and invokes the matching command directly against thatrepl_rootcontext - see the module docstring), routes lines starting with@ENTITYthroughservices.messaging.send_to_entity()(a one-off message toENTITY, resolved the same way as/target, that doesn't touch the current target), and sends anything else as a text message to the current/targettarget viaservices.messaging.send_current_target(). Either message form's text is first checked by_extract_hop_prefix()for a leading#Nhop-count override (see above), which is passed through ashops=to whichever send function handles the line.SlashCommandCompleterdrives the/commandcompletion dropdown: subcommand names at any nesting level, plus (once the current word starts with-) that command/group's own--optionflag names, plus (once a flag name is finished with=) the values of aclick.Choice-typed option (e.g.--merge=->append/overwrite/skip).repl_root.py- the rootclick.groupfor the REPL-only tree (repl_root, also aClickAliasedGroup). Its own callback is a no-op and is never actually invoked bydispatch_command()(only.get_command()/.list_commands()are used on it) - unlikecli(), there's no argv-driven setup to guard here.formatting.py/colors.py- state-free helpers:pprint()(prints throughprompt_toolkitso it plays nicely with the REPL's live input line), formatting helpers (format_position,format_dev_metrics,format_env_metrics,split_seconds,clock_emoji, etc.) used by model__str__methods, and theclrclass of raw ANSI codes used for coloring (nocoloramadependency).services/- the shared business-logic layer, with no Click dependency and no awareness of either command tree:messaging.py-do_trace/do_location/do_target/do_hops/do_conf_list/do_conf_get/do_conf_set/do_conf_apply/do_debug_toggle,log_sent_proto(), entity resolution,send_to_entity()for REPL@ENTITYlines, andsend_current_target()for ordinary REPL message lines.do_hopsreads/writes the radio'slocalConfig.lora.hop_limitdirectly (vialocalNode.writeConfig("lora")), which is what the meshtastic library falls back to whenever a send call'shopLimitisNone.node.py/channel.py/packet.py- the node and packet services are shared by shell and REPL wrappers; channel operations are REPL-only.@requires_radio(see below) is applied to shared functions that need a radio rather than to a Click wrapper.
cli.py- the shell rootclick.group(cli, usingclick_aliases.ClickAliasedGroupfor short aliases), which sets up the DB connection/tables on startup (guarded bystate.initializedso it only runs once per process - see the comment oncli()), plus the shell's own top-level commands:help,ble-scan, andlive.ble-scandiscovers nearby Meshtastic BLE devices without connecting.live(shell-only, see below) opens the Meshtastic interface (BLE/TCP/serial), subscribes to thepubsubtopics, and startsrepl.run_repl(). All radio-dependent operations are deliberately REPL-only.commands/- the shell side's offlinenodeandpacketgroups; each attaches itself tocli(imported frommushtastic.cli) via@cli.group(...).mushtastic/commands/__init__.pyimports both purely for that registration side effect.repl_commands/- the REPL side'stop(help/debug/trace/ location/target/hops/conf),node,channel, andpacketcommand definitions; each attaches itself torepl_root(imported frommushtastic.repl_root) instead ofcli, and wraps the exact sameservices.*functions as its shell-side counterpart incli.py/commands/where one exists, but with its own independent Click arguments/options/aliases/docstrings.mushtastic/repl_commands/__init__.pyimports all four purely for that registration side effect. There is deliberately no REPL counterpart forlive(see below).app.py- the thin assembler: importsclifromcli.py, importscommands(registering the shell's two subgroups) andrepl_commands(registering the REPL's four command modules), and re-exportsclifor themush = mushtastic.app:clientry point inpyproject.tomland for__init__.py/__main__.py.
Architecture notes
- Single source of truth for packets: every packet, whether received
live, sent locally, imported, or replayed, flows through
Packet.process(). If you add a new derived field or need to react to a new portnum, that's the place to do it. - Two independent command trees, one shared business-logic layer:
the shell (
mushtastic.cli.cli, populated bymushtastic.commands.*) and the REPL (mushtastic.repl_root.repl_root, populated bymushtastic.repl_commands.*) are two entirely separateclick.Grouptrees with their own Click command/group objects - separate arguments, options, aliases, help text, and tab-completion behavior. Editing one side's argument shape (or adding a new option, or tuning its completion) never touches the other. Both sides call into the same plain-function layer inmushtastic.services.*for actual behavior, so a bug fix or feature there benefits both automatically - only the Click-level plumbing is duplicated, not the logic itself. There are several deliberate exceptions where a command only exists on one side, rather than a blocklist bolted onto a shared tree - there's simply no Click command definition for it on the side that doesn't have it:liveonly exists on the shell side (cli.py) - the radio is already live by the time the REPL prompt is running, so re-entering it from inside itself makes no sense. There's nodo_liveinserviceseither.locationandtargetonly exist on the REPL side (repl_commands/top.py) -targetsets a REPL-only prompt target that doesn't persist across separate one-shotmushinvocations, andlocation's response arrives asynchronously and is just logged, which is only useful to see live.
- Adding a command requires deliberate tree selection: since the trees
do not share Click objects, add a wrapper to the shell, REPL, or both as
appropriate. Put behavior shared by multiple wrappers in
services/*.py. - Commands vs. messages in the REPL: inside
live, a line starting with/is always dispatched as a command againstrepl_root; anything else is sent as a text message to the current/targettarget (services.messaging.send_current_target()). There is intentionally no escaping mechanism for sending a literal message that starts with/. requires_radio: use this decorator (frommushtastic.decorators) on the sharedservices.*function (not on either side's Click wrapper) for any command that talks tostate.ifacedirectly (sending messages, managing channels, etc.), so it fails fast with a clear message when invoked outside oflive, on both sides at once.- Shared mutable state lives in one place: rather than module-level
globals scattered across files (which don't work across module
boundaries without extra plumbing), all cross-cutting runtime state is
on the single
stateobject fromstate.py. Import it (from .state import state) and read/writestate.<attr>directly - noglobaldeclarations needed. - Output: always print through
pprint()(wrapsprompt_toolkit.print_formatted_text) rather than bareprint()inside REPL-facing commands, so output doesn't clobber the active prompt line. - No circular imports: dependencies flow strictly downward through
services/*.pyand then out to the two independent trees (see the module dependency diagram above) -services/*.pynever importscli.py,commands/*.py,repl.py,repl_root.py, orrepl_commands/*.py. Keep new code following that direction: if logic is needed by more than one command (on either side), it belongs inservices/*.py(or, for lower-level/non-command logic,models.py/radio.py).
Adding a new command
- Put the actual behavior in
services/*.pyas a plain function (messaging.pyfor a new top-level command, ornode.py/channel.py/packet.pyfor a new subcommand of one of those groups; for a whole new command group, add a newservices/<name>.py). Decorate it with@requires_radio(frommushtastic.decorators) if it needsstate.iface. UseNode.find(name)/Packetqueries (frommushtastic.models) for lookups, andpprint()(frommushtastic.formatting) for all output. - Add a shell-side Click wrapper only if the command works without a live
radio: top-level commands go in
cli.py(@cli.command(...));node/packetsubcommands go in the matching file undercommands/. For a whole new group, register it fromcommands/__init__.py. The wrapper's body should just call the service function. - Add a REPL-side Click wrapper for interactive or radio operations, attached to
repl_root(from ..repl_root import repl_root) in the matching file underrepl_commands/instead -top.pyfor top-level commands, ornode.py/channel.py/packet.py(new group:repl_commands/<name>.py, imported fromrepl_commands/__init__.py). It's fine (expected, even) for this wrapper's arguments/options/ aliases/docstring to diverge from the shell one over time; it should still just call the sameservicesfunction.- Follow the existing style: docstring with a
Usage: /command ...section on the REPL side (Usage: mush command ...on the shell side),aliases=[...]for short forms.
- Follow the existing style: docstring with a
- If you touch packet parsing, update
Packet.process()and/orPacket.__str__()(for the colorized log line) inmodels.pyconsistently. - Reinstall in editable mode if needed (
pip install -e .), run the unit tests, and exercise the command in every command tree where it exists.
Testing
Run the unit test suite with:
python -m unittest discover -v
Tests use mocked Meshtastic interfaces and cover database persistence, packet filtering, command registration and completion, configuration staging, channel management, and serial reconnection. Changes involving the radio API should also be manually verified against a device or recorded packet input.
Build artifacts
build/, *.egg-info/, .direnv/, and local runtime files
(*.mush, *.db) are git-ignored; don't commit them.
Dependency notes
click-replwas removed: the interactive loop is now hand-rolled inrepl.pydirectly on top ofprompt-toolkit(already a dependency for the rest of the REPL UI), so there's one fewer moving part and no more click-repl-driven upper bound onclick.- Dependencies use minimum versions in
pyproject.toml. Test compatibility when raising a minimum version or adding a new dependency.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mushtastic-0.1.1.tar.gz.
File metadata
- Download URL: mushtastic-0.1.1.tar.gz
- Upload date:
- Size: 66.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3d7c99e25df8262a5bbdebcd1a1ca30e6bcb3e50d326357de4de1aba4810d6be
|
|
| MD5 |
83ac0190f58998017b7fa51f92f462a2
|
|
| BLAKE2b-256 |
c93f5fe4cce3d053fb01336ad495360b8b49778a487bf4fb18600fc9d14cad25
|
File details
Details for the file mushtastic-0.1.1-py3-none-any.whl.
File metadata
- Download URL: mushtastic-0.1.1-py3-none-any.whl
- Upload date:
- Size: 57.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
992cf344c3626b2d9bda50979392bdb32271ff3024a7f4fe97b0eb662d61c5d0
|
|
| MD5 |
82b7590fd28a29526156402b7d4c3322
|
|
| BLAKE2b-256 |
427d1991b032e7c6892d17a661137b704f595aac8638003dfacf54dc3728fb59
|