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.
- mushtastic-0.1.0/PKG-INFO +568 -0
- mushtastic-0.1.0/README.md +553 -0
- mushtastic-0.1.0/pyproject.toml +29 -0
- mushtastic-0.1.0/setup.cfg +4 -0
- mushtastic-0.1.0/src/mushtastic/__init__.py +5 -0
- mushtastic-0.1.0/src/mushtastic/__main__.py +5 -0
- mushtastic-0.1.0/src/mushtastic/app.py +26 -0
- mushtastic-0.1.0/src/mushtastic/cli.py +298 -0
- mushtastic-0.1.0/src/mushtastic/colors.py +78 -0
- mushtastic-0.1.0/src/mushtastic/commands/__init__.py +8 -0
- mushtastic-0.1.0/src/mushtastic/commands/node.py +75 -0
- mushtastic-0.1.0/src/mushtastic/commands/packet.py +119 -0
- mushtastic-0.1.0/src/mushtastic/decorators.py +21 -0
- mushtastic-0.1.0/src/mushtastic/formatting.py +136 -0
- mushtastic-0.1.0/src/mushtastic/history.py +17 -0
- mushtastic-0.1.0/src/mushtastic/models.py +395 -0
- mushtastic-0.1.0/src/mushtastic/packet_filters.py +87 -0
- mushtastic-0.1.0/src/mushtastic/prompt.py +137 -0
- mushtastic-0.1.0/src/mushtastic/radio.py +153 -0
- mushtastic-0.1.0/src/mushtastic/repl.py +348 -0
- mushtastic-0.1.0/src/mushtastic/repl_commands/__init__.py +10 -0
- mushtastic-0.1.0/src/mushtastic/repl_commands/channel.py +52 -0
- mushtastic-0.1.0/src/mushtastic/repl_commands/node.py +103 -0
- mushtastic-0.1.0/src/mushtastic/repl_commands/packet.py +185 -0
- mushtastic-0.1.0/src/mushtastic/repl_commands/top.py +149 -0
- mushtastic-0.1.0/src/mushtastic/repl_root.py +27 -0
- mushtastic-0.1.0/src/mushtastic/services/__init__.py +13 -0
- mushtastic-0.1.0/src/mushtastic/services/channel.py +129 -0
- mushtastic-0.1.0/src/mushtastic/services/messaging.py +511 -0
- mushtastic-0.1.0/src/mushtastic/services/node.py +192 -0
- mushtastic-0.1.0/src/mushtastic/services/packet.py +209 -0
- mushtastic-0.1.0/src/mushtastic/state.py +54 -0
- mushtastic-0.1.0/src/mushtastic.egg-info/PKG-INFO +568 -0
- mushtastic-0.1.0/src/mushtastic.egg-info/SOURCES.txt +39 -0
- mushtastic-0.1.0/src/mushtastic.egg-info/dependency_links.txt +1 -0
- mushtastic-0.1.0/src/mushtastic.egg-info/entry_points.txt +2 -0
- mushtastic-0.1.0/src/mushtastic.egg-info/requires.txt +8 -0
- mushtastic-0.1.0/src/mushtastic.egg-info/top_level.txt +1 -0
- mushtastic-0.1.0/tests/test_conf.py +651 -0
- mushtastic-0.1.0/tests/test_node_service.py +76 -0
- 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.
|