meshcorectl 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.
- meshcorectl/__init__.py +3 -0
- meshcorectl/__main__.py +6 -0
- meshcorectl/cli.py +243 -0
- meshcorectl/commands/__init__.py +1 -0
- meshcorectl/commands/advert.py +33 -0
- meshcorectl/commands/completion.py +40 -0
- meshcorectl/commands/config_cmd.py +186 -0
- meshcorectl/commands/create.py +65 -0
- meshcorectl/commands/delete.py +100 -0
- meshcorectl/commands/describe.py +86 -0
- meshcorectl/commands/exec_.py +107 -0
- meshcorectl/commands/get.py +183 -0
- meshcorectl/commands/login.py +115 -0
- meshcorectl/commands/logs.py +130 -0
- meshcorectl/commands/reboot.py +40 -0
- meshcorectl/commands/scan.py +76 -0
- meshcorectl/commands/send.py +124 -0
- meshcorectl/commands/set_.py +73 -0
- meshcorectl/commands/top.py +77 -0
- meshcorectl/commands/trace.py +39 -0
- meshcorectl/commands/version.py +58 -0
- meshcorectl/connect.py +74 -0
- meshcorectl/context_store.py +256 -0
- meshcorectl/durations.py +32 -0
- meshcorectl/logging_utils.py +44 -0
- meshcorectl/mesh_data.py +460 -0
- meshcorectl/output/__init__.py +109 -0
- meshcorectl/output/resources.py +73 -0
- meshcorectl/output/table.py +69 -0
- meshcorectl/py.typed +0 -0
- meshcorectl/resource_specs.py +89 -0
- meshcorectl/selectors.py +185 -0
- meshcorectl-0.1.0.dist-info/METADATA +104 -0
- meshcorectl-0.1.0.dist-info/RECORD +37 -0
- meshcorectl-0.1.0.dist-info/WHEEL +4 -0
- meshcorectl-0.1.0.dist-info/entry_points.txt +2 -0
- meshcorectl-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""`meshcorectl describe` -- verbose, human-only detail views.
|
|
2
|
+
|
|
3
|
+
Mirrors a deliberate `kubectl describe` quirk: unlike `get`, `describe`
|
|
4
|
+
ignores `-o`/`--output` entirely and always prints the same labeled,
|
|
5
|
+
multi-line text -- there's no JSON/YAML "describe" shape, only a human one.
|
|
6
|
+
For machine-readable output use `get ... -o json`/`-o yaml` instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from ..mesh_data import AmbiguousMatchError, fetch_contacts, fetch_device, find_contact
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(name="describe")
|
|
20
|
+
def describe_group() -> None:
|
|
21
|
+
"""Show a detailed, human-readable view of one resource."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@describe_group.command("device")
|
|
25
|
+
@click.pass_obj
|
|
26
|
+
def describe_device(state: Any) -> None:
|
|
27
|
+
"""Describe the connected device."""
|
|
28
|
+
device = state.call(fetch_device)
|
|
29
|
+
click.echo(_describe_device_text(device))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@describe_group.command("contact")
|
|
33
|
+
@click.argument("name")
|
|
34
|
+
@click.pass_obj
|
|
35
|
+
def describe_contact(state: Any, name: str) -> None:
|
|
36
|
+
"""Describe one contact by name or public-key prefix."""
|
|
37
|
+
contacts = state.call(fetch_contacts)
|
|
38
|
+
try:
|
|
39
|
+
contact = find_contact(contacts, name)
|
|
40
|
+
except AmbiguousMatchError as exc:
|
|
41
|
+
raise click.ClickException(str(exc)) from exc
|
|
42
|
+
if contact is None:
|
|
43
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
44
|
+
click.echo(_describe_contact_text(contact))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dash(value: Any) -> str:
|
|
48
|
+
return "-" if value is None or value == "" else str(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_epoch(epoch: Any) -> str:
|
|
52
|
+
if not epoch:
|
|
53
|
+
return "-"
|
|
54
|
+
return datetime.datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M:%S")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _describe_device_text(device: dict[str, Any]) -> str:
|
|
58
|
+
lines = [
|
|
59
|
+
f"Name: {_dash(device.get('name'))}",
|
|
60
|
+
f"Model: {_dash(device.get('model'))}",
|
|
61
|
+
f"Firmware: {_dash(device.get('ver'))} (protocol {_dash(device.get('fw ver'))})",
|
|
62
|
+
f"Build: {_dash(device.get('fw_build'))}",
|
|
63
|
+
f"Public Key: {_dash(device.get('public_key'))}",
|
|
64
|
+
"Radio: "
|
|
65
|
+
f"{_dash(device.get('radio_freq'))}MHz bw={_dash(device.get('radio_bw'))} "
|
|
66
|
+
f"sf={_dash(device.get('radio_sf'))} cr={_dash(device.get('radio_cr'))}",
|
|
67
|
+
f"Location: {_dash(device.get('adv_lat'))}, {_dash(device.get('adv_lon'))}",
|
|
68
|
+
f"Max Contacts: {_dash(device.get('max_contacts'))}",
|
|
69
|
+
f"Max Channels: {_dash(device.get('max_channels'))}",
|
|
70
|
+
f"Repeater Mode: {'yes' if device.get('repeat') else 'no'}",
|
|
71
|
+
]
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _describe_contact_text(contact: dict[str, Any]) -> str:
|
|
76
|
+
lines = [
|
|
77
|
+
f"Name: {_dash(contact.get('name'))}",
|
|
78
|
+
f"Type: {_dash(contact.get('type'))}",
|
|
79
|
+
f"Public Key: {_dash(contact.get('public_key'))}",
|
|
80
|
+
f"Path: {_dash(contact.get('path'))}",
|
|
81
|
+
f"Last Advert: {_format_epoch(contact.get('last_advert'))}",
|
|
82
|
+
f"Last Update: {_format_epoch(contact.get('lastmod'))}",
|
|
83
|
+
f"Flags: {_dash(contact.get('flags'))}",
|
|
84
|
+
f"Location: {_dash(contact.get('adv_lat'))}, {_dash(contact.get('adv_lon'))}",
|
|
85
|
+
]
|
|
86
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""`meshcorectl exec` -- run a raw console command on a repeater/room and
|
|
2
|
+
print its reply, the mesh analogue of `kubectl exec pod -- cmd`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..connect import MeshCoreConnection
|
|
12
|
+
from ..mesh_data import AmbiguousMatchError, fetch_contacts, find_contact
|
|
13
|
+
from ..mesh_data import run_repeater_command as run_repeater_command_data
|
|
14
|
+
from ..selectors import SelectorError, filter_contacts
|
|
15
|
+
|
|
16
|
+
DEFAULT_REPLY_TIMEOUT = 8.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command(name="exec", context_settings={"ignore_unknown_options": True})
|
|
20
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED, metavar="[REPEATER] -- CMD...")
|
|
21
|
+
@click.option(
|
|
22
|
+
"-l",
|
|
23
|
+
"--selector",
|
|
24
|
+
"selector_text",
|
|
25
|
+
default=None,
|
|
26
|
+
metavar="SELECTOR",
|
|
27
|
+
help="Run on every contact matching this filter instead of one by name.",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--timeout",
|
|
31
|
+
"reply_timeout",
|
|
32
|
+
type=float,
|
|
33
|
+
default=DEFAULT_REPLY_TIMEOUT,
|
|
34
|
+
show_default=True,
|
|
35
|
+
metavar="SECONDS",
|
|
36
|
+
help="How long to wait for the reply.",
|
|
37
|
+
)
|
|
38
|
+
@click.option("--dry-run", is_flag=True, help="Show what would run without running it.")
|
|
39
|
+
@click.pass_obj
|
|
40
|
+
def exec_command(
|
|
41
|
+
state: Any,
|
|
42
|
+
args: tuple[str, ...],
|
|
43
|
+
selector_text: str | None,
|
|
44
|
+
reply_timeout: float,
|
|
45
|
+
dry_run: bool,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Run a raw console command on REPEATER (or every -l/--selector match).
|
|
48
|
+
|
|
49
|
+
\b
|
|
50
|
+
Usage: meshcorectl exec REPEATER -- CMD...
|
|
51
|
+
|
|
52
|
+
REPEATER and CMD are split out of a single catch-all argument (rather
|
|
53
|
+
than two separate Click arguments) on purpose: Click fills fixed
|
|
54
|
+
positionals in declaration order regardless of `required=False`, so an
|
|
55
|
+
optional REPEATER ahead of a variadic CMD would silently steal CMD's
|
|
56
|
+
first word whenever -l/--selector was used instead. `--` is stripped by
|
|
57
|
+
Click itself wherever it appears, so it doesn't show up in `args`.
|
|
58
|
+
"""
|
|
59
|
+
tokens = list(args)
|
|
60
|
+
if selector_text is None:
|
|
61
|
+
if not tokens:
|
|
62
|
+
raise click.ClickException("pass exactly one of REPEATER or -l/--selector")
|
|
63
|
+
name: str | None = tokens[0]
|
|
64
|
+
command_tokens = tokens[1:]
|
|
65
|
+
else:
|
|
66
|
+
name = None
|
|
67
|
+
command_tokens = tokens
|
|
68
|
+
|
|
69
|
+
if not command_tokens:
|
|
70
|
+
raise click.ClickException("no command given (usage: exec REPEATER -- CMD...)")
|
|
71
|
+
command_text = " ".join(command_tokens)
|
|
72
|
+
|
|
73
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
74
|
+
contacts = await fetch_contacts(connection)
|
|
75
|
+
if name is not None:
|
|
76
|
+
try:
|
|
77
|
+
contact = find_contact(contacts, name)
|
|
78
|
+
except AmbiguousMatchError as exc:
|
|
79
|
+
raise click.ClickException(str(exc)) from exc
|
|
80
|
+
if contact is None:
|
|
81
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
82
|
+
targets = [contact]
|
|
83
|
+
else:
|
|
84
|
+
try:
|
|
85
|
+
targets = filter_contacts(contacts, selector_text)
|
|
86
|
+
except SelectorError as exc:
|
|
87
|
+
raise click.ClickException(str(exc)) from exc
|
|
88
|
+
if not targets:
|
|
89
|
+
click.echo("No contacts matched.", err=True)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
multiple = len(targets) > 1
|
|
93
|
+
for target in targets:
|
|
94
|
+
if dry_run:
|
|
95
|
+
click.echo(f"would run on {target['name']!r}: {command_text!r} (dry run)")
|
|
96
|
+
continue
|
|
97
|
+
reply = await run_repeater_command_data(
|
|
98
|
+
connection, target, command_text, timeout=reply_timeout
|
|
99
|
+
)
|
|
100
|
+
if reply is None:
|
|
101
|
+
click.echo(f"{target['name']}: (no reply within {reply_timeout}s)", err=True)
|
|
102
|
+
elif multiple:
|
|
103
|
+
click.echo(f"{target['name']}: {reply}")
|
|
104
|
+
else:
|
|
105
|
+
click.echo(reply)
|
|
106
|
+
|
|
107
|
+
state.call(run)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""`meshcorectl get` -- read resources: device, contacts, channels,
|
|
2
|
+
pending-contacts, path, time.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from meshcore import EventType
|
|
12
|
+
|
|
13
|
+
from ..mesh_data import (
|
|
14
|
+
AmbiguousMatchError,
|
|
15
|
+
collect_events,
|
|
16
|
+
fetch_channels,
|
|
17
|
+
fetch_contacts,
|
|
18
|
+
fetch_device,
|
|
19
|
+
fetch_time,
|
|
20
|
+
find_channel,
|
|
21
|
+
find_contact,
|
|
22
|
+
normalize_contact,
|
|
23
|
+
)
|
|
24
|
+
from ..output import output_option, render, resolve_output
|
|
25
|
+
from ..selectors import SelectorError, filter_contacts
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group(name="get")
|
|
29
|
+
def get_group() -> None:
|
|
30
|
+
"""Get one or more resources (device, contacts, channels, ...)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@get_group.command("device")
|
|
34
|
+
@output_option
|
|
35
|
+
@click.pass_obj
|
|
36
|
+
def get_device(state: Any, output_override: str | None) -> None:
|
|
37
|
+
"""Show the connected device's advertised info and firmware/protocol query."""
|
|
38
|
+
device = state.call(fetch_device)
|
|
39
|
+
click.echo(render(device, resolve_output(state.output, output_override), kind="device"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@get_group.command("contacts")
|
|
43
|
+
@click.option(
|
|
44
|
+
"-l",
|
|
45
|
+
"--selector",
|
|
46
|
+
"selector_text",
|
|
47
|
+
default=None,
|
|
48
|
+
metavar="SELECTOR",
|
|
49
|
+
help="Only list contacts matching this filter, e.g. 't=repeater,u<2d'.",
|
|
50
|
+
)
|
|
51
|
+
@output_option
|
|
52
|
+
@click.pass_obj
|
|
53
|
+
def get_contacts(state: Any, selector_text: str | None, output_override: str | None) -> None:
|
|
54
|
+
"""List every contact (client/repeater/room/sensor) known to the device."""
|
|
55
|
+
contacts = state.call(fetch_contacts)
|
|
56
|
+
if selector_text is not None:
|
|
57
|
+
# `is not None`, not truthy: an explicitly empty `-l ""` (e.g. an
|
|
58
|
+
# unset shell variable expanding to nothing) must be rejected by
|
|
59
|
+
# filter_contacts as an invalid selector, not silently treated the
|
|
60
|
+
# same as "-l wasn't passed at all" and match everything.
|
|
61
|
+
try:
|
|
62
|
+
contacts = filter_contacts(contacts, selector_text)
|
|
63
|
+
except SelectorError as exc:
|
|
64
|
+
raise click.ClickException(str(exc)) from exc
|
|
65
|
+
if not contacts:
|
|
66
|
+
click.echo("No contacts.", err=True)
|
|
67
|
+
return
|
|
68
|
+
click.echo(render(contacts, resolve_output(state.output, output_override), kind="contact"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@get_group.command("contact")
|
|
72
|
+
@click.argument("name")
|
|
73
|
+
@output_option
|
|
74
|
+
@click.pass_obj
|
|
75
|
+
def get_contact(state: Any, name: str, output_override: str | None) -> None:
|
|
76
|
+
"""Show one contact by name or public-key prefix."""
|
|
77
|
+
contacts = state.call(fetch_contacts)
|
|
78
|
+
try:
|
|
79
|
+
contact = find_contact(contacts, name)
|
|
80
|
+
except AmbiguousMatchError as exc:
|
|
81
|
+
raise click.ClickException(str(exc)) from exc
|
|
82
|
+
if contact is None:
|
|
83
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
84
|
+
click.echo(render(contact, resolve_output(state.output, output_override), kind="contact"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@get_group.command("channels")
|
|
88
|
+
@click.option(
|
|
89
|
+
"-A",
|
|
90
|
+
"--all",
|
|
91
|
+
"show_all",
|
|
92
|
+
is_flag=True,
|
|
93
|
+
default=False,
|
|
94
|
+
help="Include empty (unconfigured) channel slots.",
|
|
95
|
+
)
|
|
96
|
+
@output_option
|
|
97
|
+
@click.pass_obj
|
|
98
|
+
def get_channels(state: Any, show_all: bool, output_override: str | None) -> None:
|
|
99
|
+
"""List every configured channel.
|
|
100
|
+
|
|
101
|
+
A device reserves a fixed number of channel slots (commonly 40) whether
|
|
102
|
+
or not they're in use; empty slots are hidden by default -- same as
|
|
103
|
+
`send channel`/`delete channel`, which never treat one as a valid
|
|
104
|
+
target. Pass --all to include them too, in every output format.
|
|
105
|
+
"""
|
|
106
|
+
channels = state.call(fetch_channels)
|
|
107
|
+
if not show_all:
|
|
108
|
+
channels = [c for c in channels if c["name"]]
|
|
109
|
+
if not channels:
|
|
110
|
+
hint = "No channels configured."
|
|
111
|
+
if not show_all:
|
|
112
|
+
hint += " (pass --all to include empty slots)"
|
|
113
|
+
click.echo(hint, err=True)
|
|
114
|
+
return
|
|
115
|
+
click.echo(render(channels, resolve_output(state.output, output_override), kind="channel"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@get_group.command("channel")
|
|
119
|
+
@click.argument("index_or_name")
|
|
120
|
+
@output_option
|
|
121
|
+
@click.pass_obj
|
|
122
|
+
def get_channel(state: Any, index_or_name: str, output_override: str | None) -> None:
|
|
123
|
+
"""Show one channel by index or name."""
|
|
124
|
+
channels = state.call(fetch_channels)
|
|
125
|
+
channel = find_channel(channels, index_or_name)
|
|
126
|
+
if channel is None:
|
|
127
|
+
raise click.ClickException(f"no channel matching {index_or_name!r}")
|
|
128
|
+
click.echo(render(channel, resolve_output(state.output, output_override), kind="channel"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@get_group.command("pending-contacts")
|
|
132
|
+
@output_option
|
|
133
|
+
@click.pass_obj
|
|
134
|
+
def get_pending_contacts(state: Any, output_override: str | None) -> None:
|
|
135
|
+
"""Watch for adverts from not-yet-added contacts, for one timeout window.
|
|
136
|
+
|
|
137
|
+
Unlike a long-lived session, a one-shot connection has no pre-existing
|
|
138
|
+
cache of pending adverts to show: this actively listens for the
|
|
139
|
+
current --timeout (or the context/config default) and reports whatever
|
|
140
|
+
arrives in that window. Re-run (or pass a longer --timeout) to catch
|
|
141
|
+
more.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
async def watch(connection: Any) -> list[dict[str, Any]]:
|
|
145
|
+
context = state.resolve_context()
|
|
146
|
+
duration = state.effective_timeout(context)
|
|
147
|
+
return await collect_events(
|
|
148
|
+
connection, EventType.NEW_CONTACT, duration, sleep=asyncio.sleep
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
pending = state.call(watch)
|
|
152
|
+
contacts = [normalize_contact(c) for c in pending]
|
|
153
|
+
if not contacts:
|
|
154
|
+
click.echo("No pending contacts seen in this window.", err=True)
|
|
155
|
+
return
|
|
156
|
+
fmt = resolve_output(state.output, output_override)
|
|
157
|
+
click.echo(render(contacts, fmt, kind="pending-contact"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@get_group.command("path")
|
|
161
|
+
@click.argument("name")
|
|
162
|
+
@output_option
|
|
163
|
+
@click.pass_obj
|
|
164
|
+
def get_path(state: Any, name: str, output_override: str | None) -> None:
|
|
165
|
+
"""Show the routing path to a contact (flood, direct, or a hop list)."""
|
|
166
|
+
contacts = state.call(fetch_contacts)
|
|
167
|
+
try:
|
|
168
|
+
contact = find_contact(contacts, name)
|
|
169
|
+
except AmbiguousMatchError as exc:
|
|
170
|
+
raise click.ClickException(str(exc)) from exc
|
|
171
|
+
if contact is None:
|
|
172
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
173
|
+
result = {"name": contact["name"], "path": contact["path"]}
|
|
174
|
+
click.echo(render(result, resolve_output(state.output, output_override), kind="path"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@get_group.command("time")
|
|
178
|
+
@output_option
|
|
179
|
+
@click.pass_obj
|
|
180
|
+
def get_time(state: Any, output_override: str | None) -> None:
|
|
181
|
+
"""Show the device's current clock."""
|
|
182
|
+
result = state.call(fetch_time)
|
|
183
|
+
click.echo(render(result, resolve_output(state.output, output_override), kind="time"))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""`meshcorectl login`/`logout` -- authenticate to a repeater/room server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from ..connect import MeshCoreConnection
|
|
11
|
+
from ..mesh_data import AmbiguousMatchError, fetch_contacts, find_contact
|
|
12
|
+
from ..mesh_data import login as login_data
|
|
13
|
+
from ..mesh_data import logout as logout_data
|
|
14
|
+
from ..selectors import SelectorError, filter_contacts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_password(password: str | None, password_stdin: bool) -> str:
|
|
18
|
+
"""Assumes the `--password`/`--password-stdin` mutual exclusion was
|
|
19
|
+
already validated by the caller (see `login_command`) -- checked
|
|
20
|
+
early, before connecting, not buried in here."""
|
|
21
|
+
if password_stdin:
|
|
22
|
+
return sys.stdin.readline().rstrip("\n")
|
|
23
|
+
if password is not None:
|
|
24
|
+
return password
|
|
25
|
+
return click.prompt("Password", hide_input=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.command(name="login")
|
|
29
|
+
@click.argument("name", required=False, metavar="REPEATER")
|
|
30
|
+
@click.option(
|
|
31
|
+
"-l",
|
|
32
|
+
"--selector",
|
|
33
|
+
"selector_text",
|
|
34
|
+
default=None,
|
|
35
|
+
metavar="SELECTOR",
|
|
36
|
+
help="Log into every contact matching this filter instead of one by name.",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--password",
|
|
40
|
+
default=None,
|
|
41
|
+
help="Password (prefer --password-stdin, or the secure prompt, over shell history).",
|
|
42
|
+
)
|
|
43
|
+
@click.option("--password-stdin", is_flag=True, help="Read the password from stdin.")
|
|
44
|
+
@click.option("--dry-run", is_flag=True, help="Show what would run without logging in.")
|
|
45
|
+
@click.pass_obj
|
|
46
|
+
def login_command(
|
|
47
|
+
state: Any,
|
|
48
|
+
name: str | None,
|
|
49
|
+
selector_text: str | None,
|
|
50
|
+
password: str | None,
|
|
51
|
+
password_stdin: bool,
|
|
52
|
+
dry_run: bool,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Log into REPEATER (or room), or every -l/--selector match."""
|
|
55
|
+
if (name is None) == (selector_text is None):
|
|
56
|
+
raise click.ClickException("pass exactly one of REPEATER or -l/--selector")
|
|
57
|
+
if password is not None and password_stdin:
|
|
58
|
+
raise click.ClickException("pass only one of --password or --password-stdin")
|
|
59
|
+
|
|
60
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
61
|
+
contacts = await fetch_contacts(connection)
|
|
62
|
+
if name is not None:
|
|
63
|
+
try:
|
|
64
|
+
contact = find_contact(contacts, name)
|
|
65
|
+
except AmbiguousMatchError as exc:
|
|
66
|
+
raise click.ClickException(str(exc)) from exc
|
|
67
|
+
if contact is None:
|
|
68
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
69
|
+
targets = [contact]
|
|
70
|
+
else:
|
|
71
|
+
try:
|
|
72
|
+
targets = filter_contacts(contacts, selector_text)
|
|
73
|
+
except SelectorError as exc:
|
|
74
|
+
raise click.ClickException(str(exc)) from exc
|
|
75
|
+
if not targets:
|
|
76
|
+
click.echo("No contacts matched.", err=True)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if dry_run:
|
|
80
|
+
for target in targets:
|
|
81
|
+
click.echo(f"would log into {target['name']!r} (dry run)")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
secret = _resolve_password(password, password_stdin)
|
|
85
|
+
context = state.resolve_context()
|
|
86
|
+
timeout = state.effective_timeout(context)
|
|
87
|
+
for target in targets:
|
|
88
|
+
success = await login_data(connection, target, secret, timeout=timeout)
|
|
89
|
+
click.echo(f"{target['name']!r}: {'login ok' if success else 'login failed'}")
|
|
90
|
+
|
|
91
|
+
state.call(run)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.command(name="logout")
|
|
95
|
+
@click.argument("name", metavar="REPEATER")
|
|
96
|
+
@click.option("--dry-run", is_flag=True, help="Show what would run without logging out.")
|
|
97
|
+
@click.pass_obj
|
|
98
|
+
def logout_command(state: Any, name: str, dry_run: bool) -> None:
|
|
99
|
+
"""Log out of REPEATER (or room)."""
|
|
100
|
+
|
|
101
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
102
|
+
contacts = await fetch_contacts(connection)
|
|
103
|
+
try:
|
|
104
|
+
contact = find_contact(contacts, name)
|
|
105
|
+
except AmbiguousMatchError as exc:
|
|
106
|
+
raise click.ClickException(str(exc)) from exc
|
|
107
|
+
if contact is None:
|
|
108
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
109
|
+
if dry_run:
|
|
110
|
+
click.echo(f"would log out of {contact['name']!r} (dry run)")
|
|
111
|
+
return
|
|
112
|
+
await logout_data(connection, contact)
|
|
113
|
+
click.echo(f"{contact['name']!r}: logged out")
|
|
114
|
+
|
|
115
|
+
state.call(run)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""`meshcorectl logs` -- messages received by the device, the mesh analogue
|
|
2
|
+
of `kubectl logs`.
|
|
3
|
+
|
|
4
|
+
Like `kubectl logs`, this prints plain lines, not a table: a stream of
|
|
5
|
+
messages arriving over time isn't a resource list `get` would show, it's a
|
|
6
|
+
log. `-o json`/`-o yaml` still switch the line format for scripting, one
|
|
7
|
+
record per line, same as `-o json`'s effect on `kubectl logs --timestamps`
|
|
8
|
+
type output in other tools.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
import yaml
|
|
20
|
+
from meshcore import EventType
|
|
21
|
+
|
|
22
|
+
from ..connect import MeshCoreConnection
|
|
23
|
+
from ..durations import parse_duration
|
|
24
|
+
from ..mesh_data import drain_messages, fetch_channels, fetch_contacts, normalize_message
|
|
25
|
+
from ..output import OutputFormat, output_option, resolve_output
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def wait_until_interrupted() -> None:
|
|
29
|
+
"""Blocks until the process is interrupted (Ctrl-C), which `main()`
|
|
30
|
+
already translates into a clean exit. Its own module-level function
|
|
31
|
+
(rather than an inline `await asyncio.Event().wait()`) so tests can
|
|
32
|
+
monkeypatch it to return immediately instead of hanging."""
|
|
33
|
+
await asyncio.Event().wait()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_message_line(message: dict[str, Any], fmt: OutputFormat) -> str:
|
|
37
|
+
if fmt is OutputFormat.JSON:
|
|
38
|
+
return json.dumps(message)
|
|
39
|
+
if fmt is OutputFormat.YAML:
|
|
40
|
+
return yaml.safe_dump(message, sort_keys=False).rstrip("\n")
|
|
41
|
+
return f"{message['name']}: {message['text']}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.command(name="logs")
|
|
45
|
+
@click.option(
|
|
46
|
+
"-f",
|
|
47
|
+
"--follow",
|
|
48
|
+
is_flag=True,
|
|
49
|
+
help="Keep the connection open and print new messages as they arrive.",
|
|
50
|
+
)
|
|
51
|
+
@click.option(
|
|
52
|
+
"--since",
|
|
53
|
+
"since_text",
|
|
54
|
+
default=None,
|
|
55
|
+
metavar="DURATION",
|
|
56
|
+
help="Only show already-queued messages newer than this, e.g. 30m, 2h.",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--rx",
|
|
60
|
+
"raw_rx",
|
|
61
|
+
is_flag=True,
|
|
62
|
+
help="Follow the raw rx-log packet stream instead of messages (requires --follow).",
|
|
63
|
+
)
|
|
64
|
+
@output_option
|
|
65
|
+
@click.pass_obj
|
|
66
|
+
def logs_command(
|
|
67
|
+
state: Any,
|
|
68
|
+
follow: bool,
|
|
69
|
+
since_text: str | None,
|
|
70
|
+
raw_rx: bool,
|
|
71
|
+
output_override: str | None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Show messages received by the device."""
|
|
74
|
+
if raw_rx and not follow:
|
|
75
|
+
raise click.ClickException("--rx has no effect without --follow")
|
|
76
|
+
|
|
77
|
+
since_seconds: float | None = None
|
|
78
|
+
if since_text is not None:
|
|
79
|
+
try:
|
|
80
|
+
since_seconds = parse_duration(since_text)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise click.ClickException(str(exc)) from exc
|
|
83
|
+
|
|
84
|
+
fmt = resolve_output(state.output, output_override)
|
|
85
|
+
|
|
86
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
87
|
+
if raw_rx:
|
|
88
|
+
connection.subscribe(
|
|
89
|
+
EventType.RX_LOG_DATA,
|
|
90
|
+
lambda event: click.echo(json.dumps(event.payload, default=str)),
|
|
91
|
+
)
|
|
92
|
+
await wait_until_interrupted()
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
contacts = await fetch_contacts(connection)
|
|
96
|
+
channels = await fetch_channels(connection)
|
|
97
|
+
cutoff = time.time() - since_seconds if since_seconds is not None else None
|
|
98
|
+
|
|
99
|
+
def show(raw: dict[str, Any]) -> None:
|
|
100
|
+
timestamp = raw.get("sender_timestamp")
|
|
101
|
+
if cutoff is not None and timestamp is not None and timestamp < cutoff:
|
|
102
|
+
return
|
|
103
|
+
message = normalize_message(raw, contacts=contacts, channels=channels)
|
|
104
|
+
click.echo(format_message_line(message, fmt))
|
|
105
|
+
|
|
106
|
+
for raw in await drain_messages(connection):
|
|
107
|
+
show(raw)
|
|
108
|
+
|
|
109
|
+
if follow:
|
|
110
|
+
# CONTACT_MSG_RECV/CHANNEL_MSG_RECV are not passively pushed:
|
|
111
|
+
# they only fire as a side effect of an explicit
|
|
112
|
+
# commands.get_msg() call, normally triggered by a
|
|
113
|
+
# MESSAGES_WAITING notification. So subscribe to
|
|
114
|
+
# MESSAGES_WAITING and drain again (via the same
|
|
115
|
+
# drain_messages() used for the initial batch) whenever it fires.
|
|
116
|
+
fetch_task: asyncio.Task[None] | None = None
|
|
117
|
+
|
|
118
|
+
async def drain_new_messages() -> None:
|
|
119
|
+
for raw in await drain_messages(connection):
|
|
120
|
+
show(raw)
|
|
121
|
+
|
|
122
|
+
def handle_messages_waiting(_event: Any) -> None:
|
|
123
|
+
nonlocal fetch_task
|
|
124
|
+
if fetch_task is None or fetch_task.done():
|
|
125
|
+
fetch_task = asyncio.create_task(drain_new_messages())
|
|
126
|
+
|
|
127
|
+
connection.subscribe(EventType.MESSAGES_WAITING, handle_messages_waiting)
|
|
128
|
+
await wait_until_interrupted()
|
|
129
|
+
|
|
130
|
+
state.call(run)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""`meshcorectl reboot` -- reboot the connected device.
|
|
2
|
+
|
|
3
|
+
No interactive y/n prompt: `--yes` is a required flag instead, the same
|
|
4
|
+
pattern as `terraform apply -auto-approve`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from ..connect import MeshCoreConnection
|
|
14
|
+
from ..mesh_data import reboot_device
|
|
15
|
+
from ..output import output_option, render_result, resolve_output
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command(name="reboot")
|
|
19
|
+
@click.option(
|
|
20
|
+
"--yes",
|
|
21
|
+
is_flag=True,
|
|
22
|
+
help="Confirm the reboot; required, since there is no interactive prompt.",
|
|
23
|
+
)
|
|
24
|
+
@click.option("--dry-run", is_flag=True, help="Show what would happen without rebooting.")
|
|
25
|
+
@output_option
|
|
26
|
+
@click.pass_obj
|
|
27
|
+
def reboot_command(state: Any, yes: bool, dry_run: bool, output_override: str | None) -> None:
|
|
28
|
+
"""Reboot the connected device."""
|
|
29
|
+
if dry_run:
|
|
30
|
+
click.echo("would reboot the device (dry run)")
|
|
31
|
+
return
|
|
32
|
+
if not yes:
|
|
33
|
+
raise click.ClickException("pass --yes to confirm rebooting the device")
|
|
34
|
+
|
|
35
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
36
|
+
await reboot_device(connection)
|
|
37
|
+
|
|
38
|
+
state.call(run)
|
|
39
|
+
fmt = resolve_output(state.output, output_override)
|
|
40
|
+
click.echo(render_result({"rebooted": True}, fmt, "reboot requested"))
|