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 ADDED
@@ -0,0 +1,5 @@
1
+ """mushtastic package."""
2
+
3
+ from .app import cli
4
+
5
+ __all__ = ["cli"]
mushtastic/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .app import cli
2
+
3
+
4
+ if __name__ == "__main__":
5
+ cli()
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"])
@@ -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