Metadata-Version: 2.4
Name: mushtastic
Version: 0.1.1
Summary: Meshtastic live logger with an interactive command prompt
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: click>=8.1
Requires-Dist: click-aliases>=1.0.6
Requires-Dist: meshtastic>=2.5
Requires-Dist: peewee>=3.17
Requires-Dist: prompt-toolkit>=3.0.36
Requires-Dist: PyPubSub>=4.0
Requires-Dist: rich>=13.0
Requires-Dist: wcwidth>=0.2

# 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 (`peewee` ORM) - nothing is lost between
  runs.
- Interactive REPL (built directly on `prompt-toolkit`) with command
  history, `/command` autocompletion, 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 `@ENTITY` to 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 `.envrc` uses `pyenv` with Python 3.13.5
  via `direnv`).
- A Meshtastic-capable radio, reachable via USB serial, TCP (e.g.
  Wi-Fi/ESP32 nodes), or BLE.

## Install

```bash
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:

```bash
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

```bash
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.

```text
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):

```text
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`:

```text
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`:

```text
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](https://github.com/meshtastic/python/issues/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 `--help` for 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 `@ENTITY` sends just that one message
  elsewhere without changing the current target, and either message
  form can start its message text with `#N` to 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 `#N` prefix.
- `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`, and `conf apply`.
  `set` validates and stages changes locally; `apply` writes 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-psk` generates and displays a random 32-byte PSK.
    (PSK defaults to the default channel key; use `base64:...` for a
    custom key).
  - `channel rm <INDEX>` - delete a channel (not the primary one).
   - `channel mv <INDEX1> <INDEX2>` - exchange two secondary channel slots.
- `filter` controls which packets are shown live and returned by REPL packet
  commands. `filter-out` hides matching packets. Each provides `clear`,
  `from [NODE...]`, `to [NODE...]`, `channel [CHANNEL...]`, and `portnum
  [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, `N` defaults to 100 if omitted).
  - `packet show [ID] [--raw]` (`s`) - show one packet in detail (or the
    filtered set); `--raw` prints 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-scan` discovers nearby Meshtastic BLE devices without connecting.
- `mush node ls [LIMIT]`, `show NAME`, `rm ID`, and `count` query the local
  known-node database.
- `mush packet` accepts one-shot filters such as `--portnum`, `--from-id`,
  `--to-id`, packet-ID ranges, and receive-time ranges. Its `list`, `show`,
  `process`, `import`, `portnums`, and `count` subcommands operate on that
  result. For example: `mush packet --portnum POSITION_APP list 20`.

Example:

```text
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.db` in the current working directory by
  default, or the path passed to `--database`. Contains three tables:
  `node`, `packet`, and `replhistory`.
- REPL command history: timestamped rows in the active SQLite database.
- Startup creates missing tables and adds the `packet.channel` column 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 single `State` instance (imported as `from .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 `/target` target
  (`target_kind`, `selected_channel_index`, `target_node_id`), the
  last-seen telemetry for our own node, and the position display format.
  The `db = peewee.Proxy()` handle used by every model also lives here.
  Read/write it as `state.<attr>` instead of module-level globals.
- **`models.py`** - the `peewee` models:
  - `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 by `node 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 related `Node` (last-heard, telemetry, position, node-info), and
    is what both live packet reception and `packet process`/`packet
    import` call 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 `/target` target (`set_target_channel`,
  `set_target_node`), and the `pubsub` callbacks (`on_receive`, `on_log`)
  that turn incoming Meshtastic packets into `Packet` rows via
  `.process(log=True)`.
- **`decorators.py`** - the `@requires_radio` decorator, which guards
  commands that need a live radio connection (checked via `we_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 `/command` dispatcher that
  replaced `click-repl`: `run_repl()` builds one `click.Context` for
  `repl_root` per REPL session, reads lines via a `prompt_toolkit`
  `PromptSession`, routes lines starting with `/` through
  `dispatch_command()` (which resolves and invokes the matching command
  directly against that `repl_root` context - see the module docstring),
  routes lines starting with `@ENTITY` through
  `services.messaging.send_to_entity()` (a one-off message to `ENTITY`,
  resolved the same way as `/target`, that doesn't touch the current
  target), and sends anything else as a text message to the current
  `/target` target via `services.messaging.send_current_target()`. Either
  message form's text is first checked by `_extract_hop_prefix()` for a
  leading `#N` hop-count override (see above), which is passed through
  as `hops=` to whichever send function handles the line.
  `SlashCommandCompleter` drives the `/command` completion dropdown:
  subcommand names at any nesting level, plus (once the current word
  starts with `-`) that command/group's own `--option` flag names, plus
  (once a flag name is finished with `=`) the values of a
  `click.Choice`-typed option (e.g. `--merge=` -> `append`/`overwrite`/
  `skip`).
- **`repl_root.py`** - the root `click.group` for the REPL-only tree
  (`repl_root`, also a `ClickAliasedGroup`). Its own callback is a
  no-op and is never actually invoked by `dispatch_command()` (only
  `.get_command()`/`.list_commands()` are used on it) - unlike `cli()`,
  there's no argv-driven setup to guard here.
- **`formatting.py` / `colors.py`** - state-free helpers: `pprint()`
  (prints through `prompt_toolkit` so 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 the `clr`
  class of raw ANSI codes used for coloring (no `colorama` dependency).
- **`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 `@ENTITY` lines, and `send_current_target()`
    for ordinary REPL message lines. `do_hops` reads/writes the radio's
    `localConfig.lora.hop_limit` directly (via
    `localNode.writeConfig("lora")`), which is what the meshtastic
    library falls back to whenever a send call's `hopLimit` is `None`.
  - `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* root `click.group` (`cli`, using
  `click_aliases.ClickAliasedGroup` for short aliases), which sets up
  the DB connection/tables on startup (guarded by `state.initialized`
  so it only runs once per process - see the comment on `cli()`), plus
  the shell's own top-level commands: `help`, `ble-scan`, and `live`. `ble-scan`
  discovers nearby Meshtastic BLE devices without connecting. `live`
  (shell-only, see below) opens the Meshtastic interface (BLE/TCP/serial),
  subscribes to the `pubsub` topics, and starts `repl.run_repl()`. All
  radio-dependent operations are deliberately REPL-only.
- **`commands/`** - the *shell* side's offline `node` and `packet` groups;
  each attaches itself to `cli` (imported from `mushtastic.cli`) via
  `@cli.group(...)`. `mushtastic/commands/__init__.py` imports both purely
  for that registration side effect.
- **`repl_commands/`** - the *REPL* side's `top` (help/debug/trace/
  location/target/hops/conf), `node`,
  `channel`, and `packet` command definitions; each attaches itself to
  `repl_root` (imported from `mushtastic.repl_root`) instead of `cli`, and
  wraps the exact same `services.*` functions as its shell-side
  counterpart in `cli.py`/`commands/` where one exists, but with its
  own independent Click arguments/options/aliases/docstrings.
  `mushtastic/repl_commands/__init__.py` imports all four purely for that
  registration side effect. There is deliberately no REPL counterpart
  for `live` (see below).
- **`app.py`** - the thin assembler: imports `cli` from `cli.py`,
  imports `commands` (registering the shell's two subgroups) and
  `repl_commands` (registering the REPL's four command modules), and
  re-exports `cli` for the `mush = mushtastic.app:cli` entry point in
  `pyproject.toml` and 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 by `mushtastic.commands.*`) and the
  REPL (`mushtastic.repl_root.repl_root`, populated by `mushtastic.repl_commands.*`)
  are two entirely separate `click.Group` trees 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 in
  `mushtastic.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:
  - `live` only 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 no
    `do_live` in `services` either.
  - `location` and `target` only exist on the REPL side
    (`repl_commands/top.py`) - `target` sets a REPL-only prompt target that
    doesn't persist across separate one-shot `mush` invocations, and
    `location`'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 against `repl_root`;
  anything else is sent as a text message to the current `/target` target
  (`services.messaging.send_current_target()`). There is intentionally
  no escaping mechanism for sending a literal message that starts with
  `/`.
- **`requires_radio`**: use this decorator (from `mushtastic.decorators`) on
  the shared `services.*` function (not on either side's Click
  wrapper) for any command that talks to `state.iface` directly
  (sending messages, managing channels, etc.), so it fails fast with a
  clear message when invoked outside of `live`, 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 `state` object from `state.py`. Import it
  (`from .state import state`) and read/write `state.<attr>` directly -
  no `global` declarations needed.
- **Output**: always print through `pprint()` (wraps
  `prompt_toolkit.print_formatted_text`) rather than bare `print()`
  inside REPL-facing commands, so output doesn't clobber the active
  prompt line.
- **No circular imports**: dependencies flow strictly downward through
  `services/*.py` and then out to the two independent trees (see the
  module dependency diagram above) - `services/*.py` never imports
  `cli.py`, `commands/*.py`, `repl.py`, `repl_root.py`, or
  `repl_commands/*.py`. Keep new code following that direction: if
  logic is needed by more than one command (on either side), it belongs
  in `services/*.py` (or, for lower-level/non-command logic,
  `models.py`/`radio.py`).

### Adding a new command

1. Put the actual behavior in `services/*.py` as a plain function
   (`messaging.py` for a new top-level command, or `node.py`/
   `channel.py`/`packet.py` for a new subcommand of one of those
   groups; for a whole new command group, add a new `services/<name>.py`).
   Decorate it with `@requires_radio` (from `mushtastic.decorators`) if it
   needs `state.iface`. Use `Node.find(name)` / `Packet` queries (from
   `mushtastic.models`) for lookups, and `pprint()` (from `mushtastic.formatting`)
   for all output.
2. 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`/`packet` subcommands go in the matching file under `commands/`.
   For a whole new group, register it from `commands/__init__.py`. The
   wrapper's body should just call the service function.
3. 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 under `repl_commands/` instead - `top.py` for top-level
   commands, or `node.py`/`channel.py`/`packet.py` (new group:
   `repl_commands/<name>.py`, imported from `repl_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 same `services` function.
   - 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.
4. If you touch packet parsing, update `Packet.process()` and/or
   `Packet.__str__()` (for the colorized log line) in `models.py`
   consistently.
5. 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:

```bash
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-repl` was removed: the interactive loop is now hand-rolled in
  `repl.py` directly on top of `prompt-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 on `click`.
- Dependencies use minimum versions in `pyproject.toml`. Test compatibility
  when raising a minimum version or adding a new dependency.
