mushtastic 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. mushtastic-0.1.0/PKG-INFO +568 -0
  2. mushtastic-0.1.0/README.md +553 -0
  3. mushtastic-0.1.0/pyproject.toml +29 -0
  4. mushtastic-0.1.0/setup.cfg +4 -0
  5. mushtastic-0.1.0/src/mushtastic/__init__.py +5 -0
  6. mushtastic-0.1.0/src/mushtastic/__main__.py +5 -0
  7. mushtastic-0.1.0/src/mushtastic/app.py +26 -0
  8. mushtastic-0.1.0/src/mushtastic/cli.py +298 -0
  9. mushtastic-0.1.0/src/mushtastic/colors.py +78 -0
  10. mushtastic-0.1.0/src/mushtastic/commands/__init__.py +8 -0
  11. mushtastic-0.1.0/src/mushtastic/commands/node.py +75 -0
  12. mushtastic-0.1.0/src/mushtastic/commands/packet.py +119 -0
  13. mushtastic-0.1.0/src/mushtastic/decorators.py +21 -0
  14. mushtastic-0.1.0/src/mushtastic/formatting.py +136 -0
  15. mushtastic-0.1.0/src/mushtastic/history.py +17 -0
  16. mushtastic-0.1.0/src/mushtastic/models.py +395 -0
  17. mushtastic-0.1.0/src/mushtastic/packet_filters.py +87 -0
  18. mushtastic-0.1.0/src/mushtastic/prompt.py +137 -0
  19. mushtastic-0.1.0/src/mushtastic/radio.py +153 -0
  20. mushtastic-0.1.0/src/mushtastic/repl.py +348 -0
  21. mushtastic-0.1.0/src/mushtastic/repl_commands/__init__.py +10 -0
  22. mushtastic-0.1.0/src/mushtastic/repl_commands/channel.py +52 -0
  23. mushtastic-0.1.0/src/mushtastic/repl_commands/node.py +103 -0
  24. mushtastic-0.1.0/src/mushtastic/repl_commands/packet.py +185 -0
  25. mushtastic-0.1.0/src/mushtastic/repl_commands/top.py +149 -0
  26. mushtastic-0.1.0/src/mushtastic/repl_root.py +27 -0
  27. mushtastic-0.1.0/src/mushtastic/services/__init__.py +13 -0
  28. mushtastic-0.1.0/src/mushtastic/services/channel.py +129 -0
  29. mushtastic-0.1.0/src/mushtastic/services/messaging.py +511 -0
  30. mushtastic-0.1.0/src/mushtastic/services/node.py +192 -0
  31. mushtastic-0.1.0/src/mushtastic/services/packet.py +209 -0
  32. mushtastic-0.1.0/src/mushtastic/state.py +54 -0
  33. mushtastic-0.1.0/src/mushtastic.egg-info/PKG-INFO +568 -0
  34. mushtastic-0.1.0/src/mushtastic.egg-info/SOURCES.txt +39 -0
  35. mushtastic-0.1.0/src/mushtastic.egg-info/dependency_links.txt +1 -0
  36. mushtastic-0.1.0/src/mushtastic.egg-info/entry_points.txt +2 -0
  37. mushtastic-0.1.0/src/mushtastic.egg-info/requires.txt +8 -0
  38. mushtastic-0.1.0/src/mushtastic.egg-info/top_level.txt +1 -0
  39. mushtastic-0.1.0/tests/test_conf.py +651 -0
  40. mushtastic-0.1.0/tests/test_node_service.py +76 -0
  41. mushtastic-0.1.0/tests/test_packets.py +282 -0
@@ -0,0 +1,568 @@
1
+ Metadata-Version: 2.4
2
+ Name: mushtastic
3
+ Version: 0.1.0
4
+ Summary: Meshtastic live logger with an interactive command prompt
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: click-aliases>=1.0.6
9
+ Requires-Dist: meshtastic>=2.5
10
+ Requires-Dist: peewee>=3.17
11
+ Requires-Dist: prompt-toolkit>=3.0.36
12
+ Requires-Dist: PyPubSub>=4.0
13
+ Requires-Dist: rich>=13.0
14
+ Requires-Dist: wcwidth>=0.2
15
+
16
+ # mushtastic
17
+
18
+ Meshtastic live logger with an interactive command prompt.
19
+
20
+ `mushtastic` (installed as the `mush` console command) connects to a
21
+ Meshtastic radio (over USB/serial, TCP, or BLE), archives every packet
22
+ it sees into a local SQLite database, and drops you into an
23
+ interactive REPL where you can inspect nodes, browse logged packets,
24
+ send messages, manage channels, and traceroute other nodes while the
25
+ logger keeps running in the background.
26
+
27
+ ## Features
28
+
29
+ - Live packet logging to SQLite (`peewee` ORM) - nothing is lost between
30
+ runs.
31
+ - Interactive REPL (built directly on `prompt-toolkit`) with command
32
+ history, `/command` autocompletion, and a live status toolbar.
33
+ - Chat-style messaging: select a node or channel as the current target
34
+ with `/target`, then anything typed without a leading `/` is sent to it
35
+ as a text message. Prefix a single line with `@ENTITY` to send just
36
+ that message elsewhere without changing the current target.
37
+ - Node database: track short/long names, hardware info, position,
38
+ favorites, device/environment telemetry, and last-heard times.
39
+ - Packet database: filter, replay/reprocess, import, and inspect raw
40
+ packets already logged.
41
+ - Channel management: list, add, remove, and move channels directly from
42
+ the REPL.
43
+ - Send text messages, traceroutes, and position requests to nodes by
44
+ short name or node ID.
45
+
46
+ ## Requirements
47
+
48
+ - Python >= 3.10 (the checked-in `.envrc` uses `pyenv` with Python 3.13.5
49
+ via `direnv`).
50
+ - A Meshtastic-capable radio, reachable via USB serial, TCP (e.g.
51
+ Wi-Fi/ESP32 nodes), or BLE.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install -e .
57
+ ```
58
+
59
+ This installs the `mush` console script (see `[project.scripts]` in
60
+ `pyproject.toml`) backed by the `mushtastic` package in `src/mushtastic`.
61
+
62
+ If you use `direnv`, entering the project directory will automatically
63
+ activate the pyenv-managed virtualenv defined in `.envrc`.
64
+
65
+ ## Usage
66
+
67
+ Show top-level help:
68
+
69
+ ```bash
70
+ mush --help
71
+ ```
72
+
73
+ ### Global options (before the subcommand)
74
+
75
+ | Option | Description |
76
+ |---------------------|----------------------------------------------------------------------|
77
+ | `-d`, `--debug` | Enable device debug logs from startup. |
78
+ | `--database PATH` | Explicit path to the SQLite database file. |
79
+ | `--pos-fmt FORMAT` | Position display format: `latlon` (default), `google`, or `osm` link. |
80
+
81
+ Without `--database`, the DB defaults to `mush.db` in the current working directory.
82
+
83
+ ### Starting a live session
84
+
85
+ ```bash
86
+ mush live # auto-detect a USB-connected radio
87
+ mush live --dev /dev/ttyUSB0
88
+ mush live --tcp 192.168.1.50:4403
89
+ mush live --ble AA:BB:CC:DD:EE:FF
90
+ ```
91
+
92
+ Only one of `--ble`, `--tcp`, `--dev` may be given; without any of them
93
+ `mush` tries to auto-connect over serial. Once connected, `mush` drops
94
+ into a REPL (prompt shows `🐰<hops> <shortname>@<target>>`, where
95
+ `<hops>` is the radio's currently configured default hop limit - see
96
+ `/hops` - `<target>` is colored magenta for a channel or yellow for a
97
+ node) and starts archiving every received packet to the database.
98
+ Press `Ctrl+D` to exit the REPL (closes the radio interface cleanly).
99
+
100
+ Inside the REPL, commands start with `/` (e.g. `/node ls`); typing `/`
101
+ and pressing Tab shows a completion menu of available commands. A line
102
+ typed **without** a leading `/` is sent as a text message to the
103
+ currently selected target instead of being treated as a command -
104
+ select a target (a node or a channel) with `/target`:
105
+
106
+ `/target` offers a popup completion menu for matching known channels and
107
+ nodes. Type the beginning of a channel name, node short name, channel
108
+ index, or node ID and press Tab to select it.
109
+
110
+ ```text
111
+ you@primary> /target !deadbeef # target a node by ID
112
+ you@primary> /target JOHN # target a node by short name
113
+ you@primary> /target #1 # target channel 1
114
+ you@primary> /target admin # target a channel by name
115
+ you@JOHN> Hey, are you there? # sent to JOHN, no leading '/' needed
116
+ ```
117
+
118
+ If `ENTITY` (the argument to `/target`) starts with `!` it's treated as a
119
+ node ID; if it starts with `#` it's a channel number (0-7); otherwise
120
+ it's looked up by name - a channel name takes priority over a node
121
+ short name if both match. Without ever running `/target`, the target
122
+ defaults to the primary channel, matching the previous behavior.
123
+
124
+ To send a single one-off message somewhere else without changing the
125
+ current `/target` target, start the line with `@ENTITY` instead (`ENTITY`
126
+ is resolved exactly like the `/target` argument):
127
+
128
+ ```text
129
+ you@JOHN> @LongFast hello everyone! # sent to the "LongFast" channel
130
+ you@JOHN> @!deadbeef who are you? # sent to a node by ID
131
+ you@JOHN> @#1 need assistance # sent to channel 1
132
+ you@JOHN> @admin sup bro! # sent to a channel or node by name
133
+ you@JOHN> Still talking to JOHN here # /target (JOHN) is unaffected
134
+ ```
135
+
136
+ Either message form (a bare line, or one starting with `@ENTITY`) can
137
+ override the hop count for that single message with `#N` (N from 0 to
138
+ 7), and it doesn't matter whether `#N` comes before or after `@ENTITY`:
139
+
140
+ ```text
141
+ you@JOHN> #3 hello there # sent to JOHN with hop limit 3
142
+ you@JOHN> @admin #2 need help # sent to "admin" with hop limit 2
143
+ you@JOHN> #2 @admin need help # same as above - order doesn't matter
144
+ ```
145
+
146
+ Without a `#N` prefix, messages use the radio's own configured hop
147
+ limit, which can be read or changed with `/hops`:
148
+
149
+ ```text
150
+ you@primary> /hops # print the radio's currently configured hop limit
151
+ you@primary> /hops 3 # set it to 3
152
+ ```
153
+
154
+ With `--ble`, the underlying `meshtastic` library can occasionally
155
+ deadlock while disconnecting
156
+ ([meshtastic/python#792](https://github.com/meshtastic/python/issues/792),
157
+ still open as of `meshtastic` 2.7.10). `mush` bounds this with a
158
+ 5-second timeout, so a `Ctrl+D` exit will print a warning and continue
159
+ exiting instead of hanging forever if that happens.
160
+
161
+ ### Command reference
162
+
163
+ The shell CLI and interactive REPL have separate command trees. The shell
164
+ tree starts sessions and queries local data; radio operations are available
165
+ after `mush live` in the REPL. Use `mush <command> --help` or
166
+ `/<command> --help` for the authoritative syntax.
167
+
168
+ #### REPL commands
169
+
170
+ Inside `live`, prefix commands with `/`.
171
+
172
+ - `help` - print Click's `--help` for the whole app inside the REPL.
173
+ - `debug` (`d`) - toggle verbose device debug logging.
174
+ - `target <ENTITY>` - select a node or channel as the
175
+ current target (see above); subsequent lines typed without a leading
176
+ `/` are sent to it. (Not a `/`-prefixed line, but also worth noting
177
+ here: a line starting with `@ENTITY` sends just that one message
178
+ elsewhere without changing the current target, and either message
179
+ form can start its message text with `#N` to override the hop count
180
+ for that message - see above.)
181
+ - `hops [COUNT]` - get (no argument) or set (`COUNT`, 0-7) the radio's
182
+ default hop limit, used for any message that doesn't override it with
183
+ a `#N` prefix.
184
+ - `trace <NODE_ID|SHORT_NAME> [HOP_LIMIT]` (`t`, `traceroute`) - send a
185
+ traceroute request; doesn't block, the response (if any) is logged
186
+ like any other received packet.
187
+ - `location <NODE_ID|SHORT_NAME>` (`loc`) **(REPL only)** - request the
188
+ current position from a node; doesn't block, the response (if any)
189
+ is logged like any other received packet, same as with `/trace` -
190
+ since the response is only useful to see live, there's no shell
191
+ equivalent.
192
+ - `conf` (`config`) - inspect and edit writable radio configuration:
193
+ `conf list`, `conf get OPTION`, `conf set OPTION VALUE`, and `conf apply`.
194
+ `set` validates and stages changes locally; `apply` writes all staged
195
+ changes to the radio. Repeated values must be JSON arrays, such as `[1, 2]`.
196
+ - `node` (`n`, `nodes`) - manage the known-nodes table:
197
+ - `node` (no args) - show info about our own node.
198
+ - `node ls` (`l`, `list`) - list all known nodes.
199
+ - `node show <NAME>` (`s`, `i`, `info`) - show details for one node.
200
+ - `node rm <ID>` - delete a node from the local DB.
201
+ - `node fav <NAME>` - toggle favorite status (synced to the device).
202
+ - `node dls` - dump the radio's live in-memory NodeDB as JSON.
203
+ - `node pull` - copy the radio's live NodeDB into the local database.
204
+ - `node count` (`c`) - print the total number of nodes in the local DB.
205
+ - `channel` (`c`, `chan`) - manage Meshtastic channels:
206
+ - `channel` / `channel ls` (`l`, `list`) - list channels.
207
+ - `channel add <INDEX> <NAME> [PSK] [--random-psk]` - add/overwrite a channel;
208
+ `--random-psk` generates and displays a random 32-byte PSK.
209
+ (PSK defaults to the default channel key; use `base64:...` for a
210
+ custom key).
211
+ - `channel rm <INDEX>` - delete a channel (not the primary one).
212
+ - `channel mv <INDEX1> <INDEX2>` - exchange two secondary channel slots.
213
+ - `filter` controls which packets are shown live and returned by REPL packet
214
+ commands. `filter-out` hides matching packets. Each provides `clear`,
215
+ `from [NODE...]`, `to [NODE...]`, `channel [CHANNEL...]`, and `portnum
216
+ [PORTNUM...]`. No values on a field command clears that field and prints its
217
+ prior value. Within a field, values are alternatives; across fields,
218
+ inclusion filters are combined. An exclusion is applied only when every
219
+ active exclusion field matches. Filters never stop packet persistence.
220
+ - `packet` (`p`, `pkt`, `packets`) - query the local packet log:
221
+ - `packet ls [N]` (`l`, `list`) - print the filtered packets (in the
222
+ REPL, `N` defaults to 100 if omitted).
223
+ - `packet show [ID] [--raw]` (`s`) - show one packet in detail (or the
224
+ filtered set); `--raw` prints the raw JSON blob.
225
+ - `packet process` - reprocess the filtered packets (re-derive node
226
+ state such as last-heard, telemetry, position from stored packets).
227
+ - `packet reprocess` - reprocess every stored packet from its raw data,
228
+ ignoring active filters, with a progress bar.
229
+ - `packet import [--merge=skip|overwrite|append]` - read a JSON packet
230
+ blob from stdin and insert or merge it into the database.
231
+ - `packet portnums` (`p`) - show a count of stored packets per portnum.
232
+ - `packet count` (`c`) - print the count of packets in the filtered set.
233
+
234
+ #### Shell commands
235
+
236
+ - `mush ble-scan` discovers nearby Meshtastic BLE devices without connecting.
237
+ - `mush node ls [LIMIT]`, `show NAME`, `rm ID`, and `count` query the local
238
+ known-node database.
239
+ - `mush packet` accepts one-shot filters such as `--portnum`, `--from-id`,
240
+ `--to-id`, packet-ID ranges, and receive-time ranges. Its `list`, `show`,
241
+ `process`, `import`, `portnums`, and `count` subcommands operate on that
242
+ result. For example: `mush packet --portnum POSITION_APP list 20`.
243
+
244
+ Example:
245
+
246
+ ```text
247
+ mush live --dev /dev/ttyUSB0
248
+ you@primary> /node ls
249
+ you@primary> /target BASE
250
+ you@BASE> Hello from the field!
251
+ you@BASE> /filter portnum POSITION_APP
252
+ you@BASE> /packet ls 20
253
+ you@BASE> /target #1
254
+ you@admin> /help
255
+ ```
256
+
257
+ ### Data storage
258
+
259
+ - SQLite database: `./mush.db` in the current working directory by
260
+ default, or the path passed to `--database`. Contains three tables:
261
+ `node`, `packet`, and `replhistory`.
262
+ - REPL command history: timestamped rows in the active SQLite database.
263
+ - Startup creates missing tables and adds the `packet.channel` column to
264
+ databases created before channel logging was added.
265
+
266
+ The database is ignored by git (see `.gitignore`, patterns `*.mush` and
267
+ `*.db`), so it is local/runtime state, not project files. It can contain
268
+ node identities, packet contents, telemetry, and locations. Treat it as
269
+ sensitive data, and copy the SQLite file while `mush` is not running if you
270
+ need a backup or wish to move the history to another machine.
271
+
272
+ ## Development
273
+
274
+ ### Project layout
275
+
276
+ ```
277
+ mushtastic/
278
+ ├── pyproject.toml # packaging metadata, deps, entry point (mush = mushtastic.app:cli)
279
+ ├── .envrc # direnv/pyenv Python version pin
280
+ └── src/
281
+ └── mushtastic/
282
+ ├── __init__.py # re-exports `cli` from app.py
283
+ ├── __main__.py # allows `python -m mushtastic`
284
+ ├── app.py # assembles the full app (imports cli.py + commands/ + repl_commands/)
285
+ ├── cli.py # shell root Click group + top-level commands (live, ble-scan, help)
286
+ ├── commands/ # SHELL (`mush <command>`) command groups
287
+ │ ├── __init__.py # imports node/packet for their registration side-effect
288
+ │ ├── node.py # `node` command group
289
+ │ └── packet.py # `packet` command group
290
+ ├── repl_root.py # root click group for the REPL-only ('/command') tree
291
+ ├── repl_commands/ # REPL (`/command`) command groups - independent of commands/
292
+ │ ├── __init__.py # imports top/node/channel/packet for their registration side-effect
293
+ │ ├── top.py # REPL versions of help/debug/trace/location/target/hops/conf (no REPL /msg)
294
+ │ ├── node.py # REPL `node` command group
295
+ │ ├── channel.py # REPL `channel` command group
296
+ │ └── packet.py # REPL `packet` command group
297
+ ├── services/ # shared business logic (no Click deps) used by both trees
298
+ │ ├── __init__.py
299
+ │ ├── messaging.py # trace/location/target/hops/conf/debug + entity resolution/send helpers (used by shell msg + @ENTITY & current-target sends)
300
+ │ ├── node.py # node group logic
301
+ │ ├── channel.py # channel group logic
302
+ │ └── packet.py # packet group logic
303
+ ├── models.py # Peewee models: ReplHistory, Node, Packet (+ Packet.process())
304
+ ├── history.py # prompt-toolkit history backed by ReplHistory
305
+ ├── radio.py # channel/target helpers, we_are_live()/wait_for_config(), pubsub callbacks
306
+ ├── repl.py # custom REPL loop + /command dispatch (replaces click-repl)
307
+ ├── prompt.py # REPL prompt message + bottom toolbar
308
+ ├── decorators.py # @requires_radio
309
+ ├── state.py # State singleton (iface, debug flag, ...) + the peewee db Proxy
310
+ ├── formatting.py # pprint() + pure formatting helpers (dates, positions, telemetry, ...)
311
+ └── colors.py # `clr` - ANSI color/style constants
312
+ ```
313
+
314
+ The application used to live entirely in one ~1400-line `app.py`; it has
315
+ since been split by responsibility as above, and then split again into
316
+ two independent Click command trees (shell vs. REPL) sharing one
317
+ business-logic layer. Module dependencies flow one way, with no
318
+ cycles:
319
+
320
+ ```
321
+ colors.py, state.py, formatting.py (no internal deps)
322
+
323
+ models.py (state, formatting, colors)
324
+
325
+ radio.py (+ models, formatting, state)
326
+
327
+ decorators.py, prompt.py (+ radio, formatting, state, models)
328
+
329
+ services/*.py (+ decorators, radio, models, state,
330
+ formatting, colors - no Click, no
331
+ knowledge of either command tree)
332
+ ↓ ↓
333
+ cli.py repl_root.py + repl.py (each independently: + services,
334
+ ↓ ↓ colors, formatting, prompt)
335
+ commands/*.py repl_commands/*.py (+ their own root group, to attach
336
+ (+ cli.py) (+ repl_root.py) subgroups; + services/*.py)
337
+ ↓ ↓
338
+ app.py (imports cli.py + commands/ + repl_commands/, re-exports `cli`)
339
+ ```
340
+
341
+ Key pieces:
342
+
343
+ - **`state.py`** - a single `State` instance (imported as `from .state
344
+ import state`) holds all cross-command mutable runtime state: the
345
+ active Meshtastic interface (`state.iface`), our own node ID
346
+ (`state.our_node_id`), the debug flag, the current `/target` target
347
+ (`target_kind`, `selected_channel_index`, `target_node_id`), the
348
+ last-seen telemetry for our own node, and the position display format.
349
+ The `db = peewee.Proxy()` handle used by every model also lives here.
350
+ Read/write it as `state.<attr>` instead of module-level globals.
351
+ - **`models.py`** - the `peewee` models:
352
+ - `ReplHistory` - timestamped commands entered in the interactive REPL.
353
+ - `Node` - one row per known Meshtastic node (by numeric node ID).
354
+ Has `.find(name_or_id)` (resolves `!hex`, `^local`, `^all`, or a
355
+ unique short name) and a rich `__str__` used by `node ls`/`node show`.
356
+ - `Packet` - one row per logged packet, storing the raw JSON blob plus
357
+ a few indexed columns (`msg_id`, `rx_time`, `from_id`, `to_id`,
358
+ `portnum`) for fast filtering. `.process()` parses the blob, updates
359
+ the related `Node` (last-heard, telemetry, position, node-info), and
360
+ is what both live packet reception and `packet process`/`packet
361
+ import` call into.
362
+ - **`radio.py`** - channel introspection/management helpers
363
+ (`get_channel_node`, `get_current_channel_index`, `parse_psk_value`,
364
+ `find_channel_by_name`, etc.), connection state (`we_are_live()`,
365
+ `wait_for_config()`), the current `/target` target (`set_target_channel`,
366
+ `set_target_node`), and the `pubsub` callbacks (`on_receive`, `on_log`)
367
+ that turn incoming Meshtastic packets into `Packet` rows via
368
+ `.process(log=True)`.
369
+ - **`decorators.py`** - the `@requires_radio` decorator, which guards
370
+ commands that need a live radio connection (checked via `we_are_live()`).
371
+ - **`prompt.py`** - the REPL prompt (`PromptMessage`, shows
372
+ `!<node-id> <shortname> @ <target>>`; local and node targets use the
373
+ Android-derived node-ID color, channel targets use bold bright white, and
374
+ `@` and `>` are gray) and bottom-toolbar (`toolbar()`) helpers.
375
+ - **`repl.py`** - the interactive loop and `/command` dispatcher that
376
+ replaced `click-repl`: `run_repl()` builds one `click.Context` for
377
+ `repl_root` per REPL session, reads lines via a `prompt_toolkit`
378
+ `PromptSession`, routes lines starting with `/` through
379
+ `dispatch_command()` (which resolves and invokes the matching command
380
+ directly against that `repl_root` context - see the module docstring),
381
+ routes lines starting with `@ENTITY` through
382
+ `services.messaging.send_to_entity()` (a one-off message to `ENTITY`,
383
+ resolved the same way as `/target`, that doesn't touch the current
384
+ target), and sends anything else as a text message to the current
385
+ `/target` target via `services.messaging.send_current_target()`. Either
386
+ message form's text is first checked by `_extract_hop_prefix()` for a
387
+ leading `#N` hop-count override (see above), which is passed through
388
+ as `hops=` to whichever send function handles the line.
389
+ `SlashCommandCompleter` drives the `/command` completion dropdown:
390
+ subcommand names at any nesting level, plus (once the current word
391
+ starts with `-`) that command/group's own `--option` flag names, plus
392
+ (once a flag name is finished with `=`) the values of a
393
+ `click.Choice`-typed option (e.g. `--merge=` -> `append`/`overwrite`/
394
+ `skip`).
395
+ - **`repl_root.py`** - the root `click.group` for the REPL-only tree
396
+ (`repl_root`, also a `ClickAliasedGroup`). Its own callback is a
397
+ no-op and is never actually invoked by `dispatch_command()` (only
398
+ `.get_command()`/`.list_commands()` are used on it) - unlike `cli()`,
399
+ there's no argv-driven setup to guard here.
400
+ - **`formatting.py` / `colors.py`** - state-free helpers: `pprint()`
401
+ (prints through `prompt_toolkit` so it plays nicely with the REPL's
402
+ live input line), formatting helpers (`format_position`,
403
+ `format_dev_metrics`, `format_env_metrics`, `split_seconds`,
404
+ `clock_emoji`, etc.) used by model `__str__` methods, and the `clr`
405
+ class of raw ANSI codes used for coloring (no `colorama` dependency).
406
+ - **`services/`** - the shared business-logic layer, with no Click
407
+ dependency and no awareness of either command tree:
408
+ - `messaging.py` - `do_trace`/`do_location`/`do_target`/`do_hops`/
409
+ `do_conf_list`/`do_conf_get`/`do_conf_set`/`do_conf_apply`/
410
+ `do_debug_toggle`, `log_sent_proto()`, entity resolution,
411
+ `send_to_entity()` for REPL `@ENTITY` lines, and `send_current_target()`
412
+ for ordinary REPL message lines. `do_hops` reads/writes the radio's
413
+ `localConfig.lora.hop_limit` directly (via
414
+ `localNode.writeConfig("lora")`), which is what the meshtastic
415
+ library falls back to whenever a send call's `hopLimit` is `None`.
416
+ - `node.py` / `channel.py` / `packet.py` - the node and packet services
417
+ are shared by shell and REPL wrappers; channel operations are REPL-only.
418
+ `@requires_radio` (see below) is applied to shared functions that need a
419
+ radio rather than to a Click wrapper.
420
+ - **`cli.py`** - the *shell* root `click.group` (`cli`, using
421
+ `click_aliases.ClickAliasedGroup` for short aliases), which sets up
422
+ the DB connection/tables on startup (guarded by `state.initialized`
423
+ so it only runs once per process - see the comment on `cli()`), plus
424
+ the shell's own top-level commands: `help`, `ble-scan`, and `live`. `ble-scan`
425
+ discovers nearby Meshtastic BLE devices without connecting. `live`
426
+ (shell-only, see below) opens the Meshtastic interface (BLE/TCP/serial),
427
+ subscribes to the `pubsub` topics, and starts `repl.run_repl()`. All
428
+ radio-dependent operations are deliberately REPL-only.
429
+ - **`commands/`** - the *shell* side's offline `node` and `packet` groups;
430
+ each attaches itself to `cli` (imported from `mushtastic.cli`) via
431
+ `@cli.group(...)`. `mushtastic/commands/__init__.py` imports both purely
432
+ for that registration side effect.
433
+ - **`repl_commands/`** - the *REPL* side's `top` (help/debug/trace/
434
+ location/target/hops/conf), `node`,
435
+ `channel`, and `packet` command definitions; each attaches itself to
436
+ `repl_root` (imported from `mushtastic.repl_root`) instead of `cli`, and
437
+ wraps the exact same `services.*` functions as its shell-side
438
+ counterpart in `cli.py`/`commands/` where one exists, but with its
439
+ own independent Click arguments/options/aliases/docstrings.
440
+ `mushtastic/repl_commands/__init__.py` imports all four purely for that
441
+ registration side effect. There is deliberately no REPL counterpart
442
+ for `live` (see below).
443
+ - **`app.py`** - the thin assembler: imports `cli` from `cli.py`,
444
+ imports `commands` (registering the shell's two subgroups) and
445
+ `repl_commands` (registering the REPL's four command modules), and
446
+ re-exports `cli` for the `mush = mushtastic.app:cli` entry point in
447
+ `pyproject.toml` and for `__init__.py`/`__main__.py`.
448
+
449
+ ### Architecture notes
450
+
451
+ - **Single source of truth for packets**: every packet, whether received
452
+ live, sent locally, imported, or replayed, flows through
453
+ `Packet.process()`. If you add a new derived field or need to react to
454
+ a new portnum, that's the place to do it.
455
+ - **Two independent command trees, one shared business-logic layer**:
456
+ the shell (`mushtastic.cli.cli`, populated by `mushtastic.commands.*`) and the
457
+ REPL (`mushtastic.repl_root.repl_root`, populated by `mushtastic.repl_commands.*`)
458
+ are two entirely separate `click.Group` trees with their own Click
459
+ command/group objects - separate arguments, options, aliases, help
460
+ text, and tab-completion behavior. Editing one side's argument shape
461
+ (or adding a new option, or tuning its completion) never touches the
462
+ other. Both sides call into the same plain-function layer in
463
+ `mushtastic.services.*` for actual behavior, so a bug fix or feature there
464
+ benefits both automatically - only the Click-level plumbing is
465
+ duplicated, not the logic itself. There are several deliberate
466
+ exceptions where a command only exists on one side, rather than a
467
+ blocklist bolted onto a shared tree - there's simply no Click command
468
+ definition for it on the side that doesn't have it:
469
+ - `live` only exists on the shell side (`cli.py`) - the radio is
470
+ already live by the time the REPL prompt is running, so
471
+ re-entering it from inside itself makes no sense. There's no
472
+ `do_live` in `services` either.
473
+ - `location` and `target` only exist on the REPL side
474
+ (`repl_commands/top.py`) - `target` sets a REPL-only prompt target that
475
+ doesn't persist across separate one-shot `mush` invocations, and
476
+ `location`'s response arrives asynchronously and is just logged,
477
+ which is only useful to see live.
478
+ - **Adding a command requires deliberate tree selection**: since the trees
479
+ do not share Click objects, add a wrapper to the shell, REPL, or both as
480
+ appropriate. Put behavior shared by multiple wrappers in `services/*.py`.
481
+ - **Commands vs. messages in the REPL**: inside `live`, a line starting
482
+ with `/` is always dispatched as a command against `repl_root`;
483
+ anything else is sent as a text message to the current `/target` target
484
+ (`services.messaging.send_current_target()`). There is intentionally
485
+ no escaping mechanism for sending a literal message that starts with
486
+ `/`.
487
+ - **`requires_radio`**: use this decorator (from `mushtastic.decorators`) on
488
+ the shared `services.*` function (not on either side's Click
489
+ wrapper) for any command that talks to `state.iface` directly
490
+ (sending messages, managing channels, etc.), so it fails fast with a
491
+ clear message when invoked outside of `live`, on both sides at once.
492
+ - **Shared mutable state lives in one place**: rather than module-level
493
+ globals scattered across files (which don't work across module
494
+ boundaries without extra plumbing), all cross-cutting runtime state is
495
+ on the single `state` object from `state.py`. Import it
496
+ (`from .state import state`) and read/write `state.<attr>` directly -
497
+ no `global` declarations needed.
498
+ - **Output**: always print through `pprint()` (wraps
499
+ `prompt_toolkit.print_formatted_text`) rather than bare `print()`
500
+ inside REPL-facing commands, so output doesn't clobber the active
501
+ prompt line.
502
+ - **No circular imports**: dependencies flow strictly downward through
503
+ `services/*.py` and then out to the two independent trees (see the
504
+ module dependency diagram above) - `services/*.py` never imports
505
+ `cli.py`, `commands/*.py`, `repl.py`, `repl_root.py`, or
506
+ `repl_commands/*.py`. Keep new code following that direction: if
507
+ logic is needed by more than one command (on either side), it belongs
508
+ in `services/*.py` (or, for lower-level/non-command logic,
509
+ `models.py`/`radio.py`).
510
+
511
+ ### Adding a new command
512
+
513
+ 1. Put the actual behavior in `services/*.py` as a plain function
514
+ (`messaging.py` for a new top-level command, or `node.py`/
515
+ `channel.py`/`packet.py` for a new subcommand of one of those
516
+ groups; for a whole new command group, add a new `services/<name>.py`).
517
+ Decorate it with `@requires_radio` (from `mushtastic.decorators`) if it
518
+ needs `state.iface`. Use `Node.find(name)` / `Packet` queries (from
519
+ `mushtastic.models`) for lookups, and `pprint()` (from `mushtastic.formatting`)
520
+ for all output.
521
+ 2. Add a shell-side Click wrapper only if the command works without a live
522
+ radio: top-level commands go in `cli.py` (`@cli.command(...)`);
523
+ `node`/`packet` subcommands go in the matching file under `commands/`.
524
+ For a whole new group, register it from `commands/__init__.py`. The
525
+ wrapper's body should just call the service function.
526
+ 3. Add a REPL-side Click wrapper for interactive or radio operations, attached to
527
+ `repl_root` (`from ..repl_root import repl_root`) in the matching
528
+ file under `repl_commands/` instead - `top.py` for top-level
529
+ commands, or `node.py`/`channel.py`/`packet.py` (new group:
530
+ `repl_commands/<name>.py`, imported from `repl_commands/__init__.py`).
531
+ It's fine (expected, even) for this wrapper's arguments/options/
532
+ aliases/docstring to diverge from the shell one over time; it should
533
+ still just call the same `services` function.
534
+ - Follow the existing style: docstring with a `Usage: /command ...`
535
+ section on the REPL side (`Usage: mush command ...` on the shell
536
+ side), `aliases=[...]` for short forms.
537
+ 4. If you touch packet parsing, update `Packet.process()` and/or
538
+ `Packet.__str__()` (for the colorized log line) in `models.py`
539
+ consistently.
540
+ 5. Reinstall in editable mode if needed (`pip install -e .`), run the unit
541
+ tests, and exercise the command in every command tree where it exists.
542
+
543
+ ### Testing
544
+
545
+ Run the unit test suite with:
546
+
547
+ ```bash
548
+ python -m unittest discover -v
549
+ ```
550
+
551
+ Tests use mocked Meshtastic interfaces and cover database persistence,
552
+ packet filtering, command registration and completion, configuration staging,
553
+ channel management, and serial reconnection. Changes involving the radio API
554
+ should also be manually verified against a device or recorded packet input.
555
+
556
+ ### Build artifacts
557
+
558
+ `build/`, `*.egg-info/`, `.direnv/`, and local runtime files
559
+ (`*.mush`, `*.db`) are git-ignored; don't commit them.
560
+
561
+ ### Dependency notes
562
+
563
+ - `click-repl` was removed: the interactive loop is now hand-rolled in
564
+ `repl.py` directly on top of `prompt-toolkit` (already a dependency
565
+ for the rest of the REPL UI), so there's one fewer moving part and no
566
+ more click-repl-driven upper bound on `click`.
567
+ - Dependencies use minimum versions in `pyproject.toml`. Test compatibility
568
+ when raising a minimum version or adding a new dependency.