mushtastic 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- mushtastic/__main__.py +5 -0
- mushtastic/app.py +26 -0
- mushtastic/cli.py +298 -0
- mushtastic/colors.py +78 -0
- mushtastic/commands/__init__.py +8 -0
- mushtastic/commands/node.py +75 -0
- mushtastic/commands/packet.py +119 -0
- mushtastic/decorators.py +21 -0
- mushtastic/formatting.py +136 -0
- mushtastic/history.py +17 -0
- mushtastic/models.py +395 -0
- mushtastic/packet_filters.py +87 -0
- mushtastic/prompt.py +137 -0
- mushtastic/radio.py +153 -0
- mushtastic/repl.py +348 -0
- mushtastic/repl_commands/__init__.py +10 -0
- mushtastic/repl_commands/channel.py +52 -0
- mushtastic/repl_commands/node.py +103 -0
- mushtastic/repl_commands/packet.py +185 -0
- mushtastic/repl_commands/top.py +149 -0
- mushtastic/repl_root.py +27 -0
- mushtastic/services/__init__.py +13 -0
- mushtastic/services/channel.py +129 -0
- mushtastic/services/messaging.py +511 -0
- mushtastic/services/node.py +192 -0
- mushtastic/services/packet.py +209 -0
- mushtastic/state.py +54 -0
- mushtastic-0.1.0.dist-info/METADATA +568 -0
- mushtastic-0.1.0.dist-info/RECORD +33 -0
- mushtastic-0.1.0.dist-info/WHEEL +5 -0
- mushtastic-0.1.0.dist-info/entry_points.txt +2 -0
- mushtastic-0.1.0.dist-info/top_level.txt +1 -0
mushtastic/__init__.py
ADDED
mushtastic/__main__.py
ADDED
mushtastic/app.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Meshtastic live logger with an interactive command prompt.
|
|
3
|
+
- Archives every packet to sqlite db.
|
|
4
|
+
- Lets you query nodes or send messages while it runs.
|
|
5
|
+
|
|
6
|
+
This module assembles the full CLI from the rest of the package:
|
|
7
|
+
`cli.py` defines the root shell group and top-level commands (`live`,
|
|
8
|
+
`help`, `ble-scan`), and importing `mushtastic.commands` attaches the `node` and
|
|
9
|
+
`packet` command groups to it.
|
|
10
|
+
|
|
11
|
+
The REPL's `/command` tree (`mushtastic.repl_root.repl_root` +
|
|
12
|
+
`mushtastic.repl_commands.*`) is a fully separate set of Click definitions -
|
|
13
|
+
see README architecture notes - so it's assembled here too (imported
|
|
14
|
+
for its registration side effect, same as `mushtastic.commands`), even though
|
|
15
|
+
only `cli` itself needs exporting for the console-script entry point.
|
|
16
|
+
|
|
17
|
+
Run via the `mush` console script (see `pyproject.toml`) or
|
|
18
|
+
`python -m mushtastic` (see `__main__.py`) - this module isn't meant to
|
|
19
|
+
be executed directly as a script, since it relies on relative imports.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .cli import cli
|
|
23
|
+
from . import commands # noqa: F401 (registers node/packet groups)
|
|
24
|
+
from . import repl_commands # noqa: F401 (registers REPL '/node' etc. commands)
|
|
25
|
+
|
|
26
|
+
__all__ = ["cli"]
|
mushtastic/cli.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Root Click group plus shell-only, offline top-level commands.
|
|
2
|
+
|
|
3
|
+
Radio operations and runtime debugging deliberately exist only in the REPL,
|
|
4
|
+
which runs after `live` creates an interface. The `node` and `packet` command groups are defined in
|
|
5
|
+
`mushtastic.commands.*` and attach themselves to the `cli` group defined here
|
|
6
|
+
(imported by `mushtastic.app`, which is what actually assembles the full CLI).
|
|
7
|
+
|
|
8
|
+
This is the *shell* command tree only (`mush <command>`, one-shot from
|
|
9
|
+
argv). The REPL's `/<command>` tree is a fully separate set of Click
|
|
10
|
+
command/group definitions under `mushtastic.repl_commands` (rooted at
|
|
11
|
+
`mushtastic.repl_root.repl_root`) - see that package and the README
|
|
12
|
+
architecture notes for why the two are kept independent. Both trees
|
|
13
|
+
call into the same `mushtastic.services.*` functions for actual behavior, so
|
|
14
|
+
a fix there benefits both sides automatically.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
from click_aliases import ClickAliasedGroup
|
|
24
|
+
from bleak.exc import BleakError
|
|
25
|
+
from meshtastic import ble_interface, serial_interface, tcp_interface
|
|
26
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
27
|
+
from prompt_toolkit.styles import Style
|
|
28
|
+
from pubsub import pub
|
|
29
|
+
import click
|
|
30
|
+
import peewee
|
|
31
|
+
from playhouse.migrate import SqliteMigrator, migrate
|
|
32
|
+
|
|
33
|
+
from .colors import clr
|
|
34
|
+
from .formatting import pprint
|
|
35
|
+
from .history import DatabaseHistory
|
|
36
|
+
from .models import Node, Packet, ReplHistory
|
|
37
|
+
from .prompt import PromptMessage, bottom_toolbar
|
|
38
|
+
from .radio import on_log, on_receive, wait_for_config, we_are_live
|
|
39
|
+
from .repl import run_repl
|
|
40
|
+
from .state import db, state
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
###################
|
|
44
|
+
# Root group
|
|
45
|
+
###################
|
|
46
|
+
|
|
47
|
+
def _close_iface(timeout=5.0):
|
|
48
|
+
"""Close state.iface without letting the app hang forever.
|
|
49
|
+
|
|
50
|
+
meshtastic's BLEInterface can deadlock inside close()/disconnect()
|
|
51
|
+
(a bleak disconnected_callback re-entering close() from its own
|
|
52
|
+
asyncio event loop thread - see
|
|
53
|
+
https://github.com/meshtastic/python/issues/792, still open as of
|
|
54
|
+
meshtastic 2.7.10). Run close() in a daemon thread and give up
|
|
55
|
+
after `timeout` seconds instead of blocking the REPL exit forever;
|
|
56
|
+
the device side of the disconnect has normally already happened by
|
|
57
|
+
then, we're just waiting on the library's own bookkeeping.
|
|
58
|
+
"""
|
|
59
|
+
iface = state.iface
|
|
60
|
+
if iface is None:
|
|
61
|
+
return
|
|
62
|
+
done = threading.Event()
|
|
63
|
+
|
|
64
|
+
def _do_close():
|
|
65
|
+
try:
|
|
66
|
+
iface.close()
|
|
67
|
+
finally:
|
|
68
|
+
done.set()
|
|
69
|
+
|
|
70
|
+
threading.Thread(target=_do_close, daemon=True).start()
|
|
71
|
+
if not done.wait(timeout):
|
|
72
|
+
pprint(
|
|
73
|
+
f"{clr.faint}Interface didn't close within {timeout:.0f}s "
|
|
74
|
+
f"(known meshtastic BLE bug), exiting anyway.{clr.reset}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ensure_packet_channel_column(db_obj):
|
|
79
|
+
"""Add Packet.channel to databases created before the column existed."""
|
|
80
|
+
columns = {column.name for column in db_obj.get_columns(Packet._meta.table_name)}
|
|
81
|
+
if "channel" not in columns:
|
|
82
|
+
migrate(SqliteMigrator(db_obj).add_column("packet", "channel", Packet.channel))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _reconnect_serial_loop(stop_event, reconnecting, dev, iface_factory=None):
|
|
86
|
+
"""Replace a disconnected serial interface, retrying until shutdown."""
|
|
87
|
+
iface_factory = iface_factory or serial_interface.SerialInterface
|
|
88
|
+
delay = 1
|
|
89
|
+
try:
|
|
90
|
+
while not stop_event.is_set():
|
|
91
|
+
# A reset signalled over a still-open stream is recovered by
|
|
92
|
+
# Meshtastic itself. If that recovery instead turns into a serial
|
|
93
|
+
# disconnect, stream becomes None and this loop takes over.
|
|
94
|
+
current_iface = state.iface
|
|
95
|
+
if getattr(current_iface, "stream", None) is not None:
|
|
96
|
+
if current_iface.isConnected.is_set():
|
|
97
|
+
return
|
|
98
|
+
stop_event.wait(0.25)
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
iface = iface_factory(devPath=dev or None)
|
|
102
|
+
if stop_event.is_set():
|
|
103
|
+
iface.close()
|
|
104
|
+
return
|
|
105
|
+
# SerialInterface waits for Meshtastic config during its
|
|
106
|
+
# constructor, so myInfo is available before publishing it.
|
|
107
|
+
state.iface = iface
|
|
108
|
+
state.our_node_id = f"!{iface.myInfo.my_node_num:08x}"
|
|
109
|
+
pprint(f"{clr.faint}Radio reconnected.{clr.r}")
|
|
110
|
+
return
|
|
111
|
+
except Exception as error:
|
|
112
|
+
pprint(f"{clr.faint}Radio reconnect failed: {error}. Retrying in {delay}s.{clr.r}")
|
|
113
|
+
stop_event.wait(delay)
|
|
114
|
+
delay = min(delay * 2, 10)
|
|
115
|
+
finally:
|
|
116
|
+
reconnecting.clear()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@contextmanager
|
|
120
|
+
def _serial_reconnect_events(enabled, dev):
|
|
121
|
+
"""Manage serial-radio reconnect callbacks for one live session."""
|
|
122
|
+
if not enabled:
|
|
123
|
+
yield
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
stop_reconnect = threading.Event()
|
|
127
|
+
reconnecting = threading.Event()
|
|
128
|
+
|
|
129
|
+
def on_connection_lost(interface):
|
|
130
|
+
if stop_reconnect.is_set() or interface is not state.iface or reconnecting.is_set():
|
|
131
|
+
return
|
|
132
|
+
reconnecting.set()
|
|
133
|
+
pprint(f"{clr.faint}Radio restarting; waiting for configuration.{clr.r}")
|
|
134
|
+
threading.Thread(
|
|
135
|
+
target=_reconnect_serial_loop,
|
|
136
|
+
args=(stop_reconnect, reconnecting, dev),
|
|
137
|
+
daemon=True,
|
|
138
|
+
).start()
|
|
139
|
+
|
|
140
|
+
def on_connection_established(interface):
|
|
141
|
+
if stop_reconnect.is_set() or interface is not state.iface:
|
|
142
|
+
return
|
|
143
|
+
state.our_node_id = f"!{interface.myInfo.my_node_num:08x}"
|
|
144
|
+
pprint(f"{clr.faint}Radio connection established.{clr.r}")
|
|
145
|
+
|
|
146
|
+
pub.subscribe(on_connection_lost, "meshtastic.connection.lost")
|
|
147
|
+
pub.subscribe(on_connection_established, "meshtastic.connection.established")
|
|
148
|
+
try:
|
|
149
|
+
yield
|
|
150
|
+
finally:
|
|
151
|
+
stop_reconnect.set()
|
|
152
|
+
pub.unsubscribe(on_connection_lost, "meshtastic.connection.lost")
|
|
153
|
+
pub.unsubscribe(on_connection_established, "meshtastic.connection.established")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@click.group(cls=ClickAliasedGroup, invoke_without_command=True)
|
|
157
|
+
@click.version_option(None, "-v", "--version", package_name="mushtastic")
|
|
158
|
+
@click.option("-d", "--debug", is_flag=True, help="Enable device debug logs.")
|
|
159
|
+
@click.option("--database", type=str, help="SQLite database file path.")
|
|
160
|
+
@click.option(
|
|
161
|
+
"--pos-fmt",
|
|
162
|
+
type=click.Choice(["latlon", "google", "osm"]),
|
|
163
|
+
required=False,
|
|
164
|
+
default="latlon",
|
|
165
|
+
help="Position display format.",
|
|
166
|
+
)
|
|
167
|
+
@click.pass_context
|
|
168
|
+
def cli(ctx, debug=False, database=None, pos_fmt=None):
|
|
169
|
+
ctx.ensure_object(dict)
|
|
170
|
+
ctx.obj["db"] = db
|
|
171
|
+
|
|
172
|
+
# This callback re-runs every time a command is dispatched from the
|
|
173
|
+
# REPL loop (see repl.py), since Click re-invokes a Group's own
|
|
174
|
+
# callback whenever it dispatches to one of its subcommands. Only
|
|
175
|
+
# apply options and (re)connect the database on the very first
|
|
176
|
+
# (real, argv-driven) invocation - otherwise every REPL command
|
|
177
|
+
# would reset state.debug/position_format back to their defaults
|
|
178
|
+
# and leak a new sqlite connection on every keystroke.
|
|
179
|
+
if state.initialized:
|
|
180
|
+
return
|
|
181
|
+
if debug:
|
|
182
|
+
state.debug = True
|
|
183
|
+
state.db_path = Path("mush.db")
|
|
184
|
+
if database:
|
|
185
|
+
state.db_path = Path(database)
|
|
186
|
+
if pos_fmt:
|
|
187
|
+
state.position_format = pos_fmt
|
|
188
|
+
db_obj = peewee.SqliteDatabase(Path(state.db_path).expanduser())
|
|
189
|
+
db.initialize(db_obj)
|
|
190
|
+
db_obj.connect()
|
|
191
|
+
db_obj.create_tables([Node, Packet, ReplHistory])
|
|
192
|
+
_ensure_packet_channel_column(db_obj)
|
|
193
|
+
state.initialized = True
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@click.pass_context
|
|
198
|
+
def help(ctx):
|
|
199
|
+
"""Show Click's --help for the shell command tree (`mush --help`)."""
|
|
200
|
+
pprint(f"{clr.faint}{ctx.parent.command.get_help(ctx)}{clr.reset}")
|
|
201
|
+
pprint(f"{clr.faint} Use: 'command --help' with specific commands.{clr.reset}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@cli.command(name="ble-scan")
|
|
205
|
+
def ble_scan():
|
|
206
|
+
"""List nearby Meshtastic BLE devices without connecting to one."""
|
|
207
|
+
try:
|
|
208
|
+
devices = ble_interface.BLEInterface.scan()
|
|
209
|
+
except BleakError as error:
|
|
210
|
+
pprint(f"{clr.faint}BLE scan failed: {error}{clr.r}")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
if not devices:
|
|
214
|
+
pprint(f"{clr.faint}No Meshtastic BLE devices found.{clr.r}")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
for device in devices:
|
|
218
|
+
pprint(f"Found: name={device.name!r} address={device.address!r}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@cli.command(short_help="Connect to a radio and open the REPL.")
|
|
222
|
+
@click.pass_context
|
|
223
|
+
@click.option("--ble", type=str, help="Bluetooth device address.")
|
|
224
|
+
@click.option("--tcp", type=str, help="TCP host or host:port.")
|
|
225
|
+
@click.option("--dev", type=str, help="Serial device path.")
|
|
226
|
+
def live(ctx: click.Context, ble=None, tcp=None, dev=None):
|
|
227
|
+
"""
|
|
228
|
+
Connect to a radio and start an interactive REPL with all commands
|
|
229
|
+
loaded.
|
|
230
|
+
|
|
231
|
+
Usage: mush live [OPTIONS]
|
|
232
|
+
Options:
|
|
233
|
+
--ble BLUETOOTH_DEVICE - Connect to a bluetooth device.
|
|
234
|
+
--tcp IP|URL:PORT - Connect to TCP enabled device.
|
|
235
|
+
--dev DEVICE_PATH - Connect to USB attached device.
|
|
236
|
+
If no device is specified, it will try to autoconnect.
|
|
237
|
+
|
|
238
|
+
Once connected, commands are entered with a leading '/' (e.g.
|
|
239
|
+
'/node ls'). Anything typed without a leading '/' is sent as a text
|
|
240
|
+
message to the currently selected target (see '/target').
|
|
241
|
+
|
|
242
|
+
Shell-only: the radio is already live by the time the REPL prompt
|
|
243
|
+
starts, so this isn't available as a '/live' REPL command - there's
|
|
244
|
+
simply no such command in `mushtastic.repl_commands` (see that package).
|
|
245
|
+
"""
|
|
246
|
+
if we_are_live():
|
|
247
|
+
raise Exception("Starting live session while we are live.")
|
|
248
|
+
|
|
249
|
+
if sum(v is not None for v in (ble, tcp, dev)) > 1:
|
|
250
|
+
raise Exception("Only 1 of the interfaces (ble,tcp,dev) can be used.")
|
|
251
|
+
|
|
252
|
+
state.iface = None
|
|
253
|
+
try:
|
|
254
|
+
if ble is not None:
|
|
255
|
+
state.iface = ble_interface.BLEInterface(address=ble or None)
|
|
256
|
+
elif tcp:
|
|
257
|
+
host, *p = tcp.split(":")
|
|
258
|
+
state.iface = tcp_interface.TCPInterface(
|
|
259
|
+
host=host, port=int(p[0]) if p else 4403)
|
|
260
|
+
else:
|
|
261
|
+
state.iface = serial_interface.SerialInterface(devPath=dev or None)
|
|
262
|
+
wait_for_config()
|
|
263
|
+
except Exception as error:
|
|
264
|
+
_close_iface()
|
|
265
|
+
state.iface = None
|
|
266
|
+
raise click.ClickException(f"Could not connect to radio: {error}") from None
|
|
267
|
+
|
|
268
|
+
state.our_node_id = f"!{state.iface.myInfo.my_node_num:08x}"
|
|
269
|
+
try:
|
|
270
|
+
us = Node.find(state.our_node_id)
|
|
271
|
+
if us.dev_metrics:
|
|
272
|
+
state.our_last_metrics = json.loads(us.dev_metrics)
|
|
273
|
+
except Packet.DoesNotExist:
|
|
274
|
+
pprint("Can't find our radio in DB yet...")
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
with _serial_reconnect_events(ble is None and not tcp, dev):
|
|
278
|
+
with patch_stdout():
|
|
279
|
+
pub.subscribe(on_receive, "meshtastic.receive")
|
|
280
|
+
pub.subscribe(on_log, "meshtastic.log.line")
|
|
281
|
+
pprint(f"[{datetime.now()}] Connected to meshtastic console.")
|
|
282
|
+
style = Style.from_dict({
|
|
283
|
+
"bottom-toolbar": "bg:#000000 bold",
|
|
284
|
+
"packet-filter-toolbar": "#ffffff",
|
|
285
|
+
"status-toolbar": "bg:#000000 #888888",
|
|
286
|
+
})
|
|
287
|
+
prompt_kwargs = {
|
|
288
|
+
"history": DatabaseHistory(),
|
|
289
|
+
"enable_history_search": True,
|
|
290
|
+
"complete_while_typing": True,
|
|
291
|
+
"bottom_toolbar": bottom_toolbar,
|
|
292
|
+
"style": style,
|
|
293
|
+
"message": PromptMessage(),
|
|
294
|
+
}
|
|
295
|
+
pprint("Type '/' for a list of subcommands, 'ctrl+d' to exit.")
|
|
296
|
+
run_repl(prompt_kwargs)
|
|
297
|
+
finally:
|
|
298
|
+
_close_iface()
|
mushtastic/colors.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""ANSI color/style constants used for terminal output."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class clr:
|
|
5
|
+
"""
|
|
6
|
+
All 16 ANSI foreground colours, their 16 background counterparts,
|
|
7
|
+
plus a handful of style toggles.
|
|
8
|
+
"""
|
|
9
|
+
# ───── Resets ─────────────────────────────────────────────────────────
|
|
10
|
+
reset = "\033[0m"
|
|
11
|
+
r = reset
|
|
12
|
+
# ───── Styles ────────────────────────────────────────────────────────
|
|
13
|
+
bold = "\033[1m"
|
|
14
|
+
faint = "\033[2m"
|
|
15
|
+
italic = "\033[3m"
|
|
16
|
+
underline = "\033[4m"
|
|
17
|
+
inverse = "\033[7m"
|
|
18
|
+
# ───── 8 standard foreground colours ────────────────────────────────
|
|
19
|
+
black = "\033[30m"
|
|
20
|
+
red = "\033[31m"
|
|
21
|
+
green = "\033[32m"
|
|
22
|
+
yellow = "\033[33m"
|
|
23
|
+
blue = "\033[34m"
|
|
24
|
+
magenta = "\033[35m"
|
|
25
|
+
cyan = "\033[36m"
|
|
26
|
+
white = "\033[37m"
|
|
27
|
+
grey = "\033[90m"
|
|
28
|
+
# ───── 8 bright foreground colours (a.k.a. “high-intensity”) ────────
|
|
29
|
+
bright_black = "\033[90m" # ≈ grey
|
|
30
|
+
bright_red = "\033[91m"
|
|
31
|
+
bright_green = "\033[92m"
|
|
32
|
+
bright_yellow = "\033[93m"
|
|
33
|
+
bright_blue = "\033[94m"
|
|
34
|
+
bright_magenta = "\033[95m"
|
|
35
|
+
bright_cyan = "\033[96m"
|
|
36
|
+
bright_white = "\033[97m"
|
|
37
|
+
# ───── 8 standard background colours ────────────────────────────────
|
|
38
|
+
black_bg = "\033[40m"
|
|
39
|
+
red_bg = "\033[41m"
|
|
40
|
+
green_bg = "\033[42m"
|
|
41
|
+
yellow_bg = "\033[43m"
|
|
42
|
+
blue_bg = "\033[44m"
|
|
43
|
+
magenta_bg = "\033[45m"
|
|
44
|
+
cyan_bg = "\033[46m"
|
|
45
|
+
white_bg = "\033[47m"
|
|
46
|
+
# ───── 8 bright background colours ──────────────────────────────────
|
|
47
|
+
bright_black_bg = "\033[100m"
|
|
48
|
+
bright_red_bg = "\033[101m"
|
|
49
|
+
bright_green_bg = "\033[102m"
|
|
50
|
+
bright_yellow_bg = "\033[103m"
|
|
51
|
+
bright_blue_bg = "\033[104m"
|
|
52
|
+
bright_magenta_bg = "\033[105m"
|
|
53
|
+
bright_cyan_bg = "\033[106m"
|
|
54
|
+
bright_white_bg = "\033[107m"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _node_color_parts(node_num):
|
|
58
|
+
"""Return Meshtastic Android's RGB and contrasting foreground for a node."""
|
|
59
|
+
red = (node_num & 0xFF0000) >> 16
|
|
60
|
+
green = (node_num & 0x00FF00) >> 8
|
|
61
|
+
blue = node_num & 0x0000FF
|
|
62
|
+
brightness = (0.299 * red + 0.587 * green + 0.114 * blue) / 255
|
|
63
|
+
return red, green, blue, brightness > 0.5
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def node_text(node_num, text):
|
|
67
|
+
"""Color node text using Meshtastic Android's node-number RGB mapping."""
|
|
68
|
+
red, green, blue, use_black = _node_color_parts(node_num)
|
|
69
|
+
foreground = clr.black if use_black else clr.white
|
|
70
|
+
background = f"\033[48;2;{red};{green};{blue}m"
|
|
71
|
+
return f"{background}{foreground}{text}{clr.reset}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def node_prompt_style(node_num):
|
|
75
|
+
"""Return a prompt_toolkit style using the Android node-number colors."""
|
|
76
|
+
red, green, blue, use_black = _node_color_parts(node_num)
|
|
77
|
+
foreground = "#000000" if use_black else "#ffffff"
|
|
78
|
+
return f"bg:#{red:02x}{green:02x}{blue:02x} fg:{foreground}"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Command groups (`node`, `packet`) that attach themselves to
|
|
2
|
+
the root `cli` group from `mushtastic.cli`.
|
|
3
|
+
|
|
4
|
+
Importing this package (as `mushtastic.app` does) is what registers both
|
|
5
|
+
groups - the imports below are for that side effect only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import node, packet # noqa: F401
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""`node` command group: manage the local known-nodes table.
|
|
2
|
+
|
|
3
|
+
This is the shell (`mush node ...`) side. The REPL (`/node ...`) side
|
|
4
|
+
is a fully independent set of Click definitions in
|
|
5
|
+
`mushtastic.repl_commands.node`; both call into `mushtastic.services.node.*` for
|
|
6
|
+
actual behavior (see README architecture notes / `mushtastic.services`
|
|
7
|
+
docstring).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from click_aliases import ClickAliasedGroup
|
|
12
|
+
|
|
13
|
+
from ..cli import cli
|
|
14
|
+
from ..services import node as node_service
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cli.group(
|
|
18
|
+
cls=ClickAliasedGroup,
|
|
19
|
+
invoke_without_command=True,
|
|
20
|
+
aliases=['n', 'nodes'],
|
|
21
|
+
short_help="Manage known nodes.",
|
|
22
|
+
)
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def node(ctx: click.Context):
|
|
25
|
+
"""
|
|
26
|
+
Manipulate device/database known nodes.
|
|
27
|
+
|
|
28
|
+
Usage: node [COMMAND]
|
|
29
|
+
If used without a sub-command it will print known info about self.
|
|
30
|
+
"""
|
|
31
|
+
if ctx.invoked_subcommand:
|
|
32
|
+
return
|
|
33
|
+
node_service.show_self()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@node.command(name="list", aliases=["l", "ls"])
|
|
37
|
+
@click.argument("limit", type=click.IntRange(min=1), required=False, default=100)
|
|
38
|
+
def node_list(limit):
|
|
39
|
+
"""
|
|
40
|
+
List recently heard known nodes.
|
|
41
|
+
|
|
42
|
+
Usage: node ls [COUNT]
|
|
43
|
+
"""
|
|
44
|
+
node_service.list_nodes(limit)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@node.command(name="rm")
|
|
48
|
+
@click.argument("id", type=str, required=True)
|
|
49
|
+
def node_rm(id):
|
|
50
|
+
"""
|
|
51
|
+
Delete a node from the local DB.
|
|
52
|
+
|
|
53
|
+
Usage: node rm <ID>
|
|
54
|
+
"""
|
|
55
|
+
node_service.rm_node(id)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@node.command(name="show", aliases=["s", "i", "info"])
|
|
59
|
+
@click.argument("name", type=str, required=True)
|
|
60
|
+
def node_show(name):
|
|
61
|
+
"""
|
|
62
|
+
Show known info about a node.
|
|
63
|
+
|
|
64
|
+
Usage: node show <SHORT_NAME|NODE_ID>
|
|
65
|
+
"""
|
|
66
|
+
node_service.show_node(name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@node.command(name="count", aliases=["c"])
|
|
70
|
+
def node_count():
|
|
71
|
+
"""
|
|
72
|
+
Print the total number of nodes stored in the local DB.
|
|
73
|
+
Usage: node count
|
|
74
|
+
"""
|
|
75
|
+
node_service.count_nodes()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""`packet` command group: query, inspect, reprocess, and import packets
|
|
2
|
+
stored in the local database.
|
|
3
|
+
|
|
4
|
+
This is the shell (`mush packet ...`) side. The REPL (`/packet ...`)
|
|
5
|
+
side is a fully independent set of Click definitions in
|
|
6
|
+
`mushtastic.repl_commands.packet`; both call into `mushtastic.services.packet.*`
|
|
7
|
+
for actual behavior (see README architecture notes / `mushtastic.services`
|
|
8
|
+
docstring).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from click_aliases import ClickAliasedGroup
|
|
13
|
+
|
|
14
|
+
from ..cli import cli
|
|
15
|
+
from ..services import packet as packet_service
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cli.group(cls=ClickAliasedGroup, aliases=['p', 'pkt', 'packets'], short_help="List stored packets.")
|
|
19
|
+
@click.option("--id", type=str, help="Packet ID.")
|
|
20
|
+
@click.option("--msg-id", type=int, help="Meshtastic message ID.")
|
|
21
|
+
@click.option("--limit", type=int, help="Maximum number of packets.")
|
|
22
|
+
@click.option("--portnum", type=str, help="Packet port number.")
|
|
23
|
+
@click.option("--from-id", type=str, help="Sender node ID.")
|
|
24
|
+
@click.option("--to-id", type=str, help="Recipient node ID.")
|
|
25
|
+
@click.option("--id-start", type=str, help="First packet ID, inclusive.")
|
|
26
|
+
@click.option("--id-end", type=str, help="Last packet ID, inclusive.")
|
|
27
|
+
@click.option("--rx-time-start", type=int, help="Earliest receive timestamp.")
|
|
28
|
+
@click.option("--rx-time-end", type=int, help="Latest receive timestamp.")
|
|
29
|
+
@click.pass_context
|
|
30
|
+
def packet(ctx: click.Context, id=None, msg_id=None, limit=None, portnum=None,
|
|
31
|
+
from_id=None, to_id=None, id_start=None, id_end=None,
|
|
32
|
+
rx_time_start=None, rx_time_end=None):
|
|
33
|
+
"""
|
|
34
|
+
Print the last N packets stored in the database, with optional column
|
|
35
|
+
filters of the form field=value.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
packet ls <N> [field=value …]
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
packet ls 20 # last 20 packets
|
|
42
|
+
packet --portnum=POSITION_APP ls # only position packets
|
|
43
|
+
packet ls 10 --from_id=!abcdef01 --to_id=^all
|
|
44
|
+
"""
|
|
45
|
+
ctx.ensure_object(dict)
|
|
46
|
+
ctx.obj["packets"] = packet_service.filter_packets(
|
|
47
|
+
id=id, msg_id=msg_id, limit=limit, portnum=portnum,
|
|
48
|
+
from_id=from_id, to_id=to_id, id_start=id_start, id_end=id_end,
|
|
49
|
+
rx_time_start=rx_time_start, rx_time_end=rx_time_end,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@packet.command(name="show", aliases=["s"])
|
|
54
|
+
@click.argument("id", type=str, required=False, default=None)
|
|
55
|
+
@click.option("--raw", is_flag=True)
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def packet_show(ctx, id=None, raw=False):
|
|
58
|
+
packet_service.show_packets(ctx.obj["packets"], id=id, raw=raw)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@packet.command()
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def process(ctx):
|
|
64
|
+
"""
|
|
65
|
+
Process again one or all packets from the database.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
packet process ID (this is db entry ID, not msg_id)
|
|
69
|
+
packet process --all (process all db entries)
|
|
70
|
+
"""
|
|
71
|
+
packet_service.process_packets(ctx.obj["packets"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@packet.command(name="import")
|
|
75
|
+
@click.option("--merge", type=click.Choice(["append", "overwrite", "skip"],
|
|
76
|
+
case_sensitive=False), default="skip")
|
|
77
|
+
def packet_import(merge="skip"):
|
|
78
|
+
packet_service.import_packet(merge=merge)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@packet.command(name="list", aliases=["l", "ls"])
|
|
82
|
+
@click.argument("limit", type=int, required=False, default=None)
|
|
83
|
+
@click.pass_context
|
|
84
|
+
def packet_list(ctx, limit):
|
|
85
|
+
"""
|
|
86
|
+
Print the last N packets stored in the database, with optional column
|
|
87
|
+
filters of the form field=value.
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
packet ls <N> [field=value …]
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
packet ls 20 # last 20 packets
|
|
94
|
+
packet ls 50 --portnum=POSITION_APP # only position packets
|
|
95
|
+
packet ls 10 --from_id=!abcdef01 --to_id=^all
|
|
96
|
+
"""
|
|
97
|
+
packet_service.list_packets(ctx.obj["packets"], limit=limit)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@packet.command(name="portnums", aliases=["p"])
|
|
101
|
+
def packet_portnums():
|
|
102
|
+
"""
|
|
103
|
+
Show a count of each distinct portnum stored in the packets table.
|
|
104
|
+
Usage: ports
|
|
105
|
+
"""
|
|
106
|
+
packet_service.count_portnums()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@packet.command(name="count", aliases=["c"])
|
|
110
|
+
@click.pass_context
|
|
111
|
+
def packet_count(ctx):
|
|
112
|
+
"""
|
|
113
|
+
Print the number of packets in the currently filtered set.
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
packet count # count of all packets
|
|
117
|
+
packet --portnum=POSITION_APP count # count of position packets
|
|
118
|
+
"""
|
|
119
|
+
packet_service.count_packets(ctx.obj["packets"])
|
mushtastic/decorators.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Click command decorators shared across command modules."""
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .formatting import pprint
|
|
7
|
+
from .radio import we_are_live
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def requires_radio(func):
|
|
11
|
+
"""
|
|
12
|
+
Decorator that asserts we_are_live() before calling *func*.
|
|
13
|
+
"""
|
|
14
|
+
@wraps(func)
|
|
15
|
+
def wrapper(*args, **kwargs):
|
|
16
|
+
if not we_are_live():
|
|
17
|
+
pprint("This service requires live radio.")
|
|
18
|
+
pprint("Use: 'mush live'")
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
return func(*args, **kwargs)
|
|
21
|
+
return wrapper
|