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
meshcorectl/__init__.py
ADDED
meshcorectl/__main__.py
ADDED
meshcorectl/cli.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""The root Click group: global flags, logging setup, and `CliState`.
|
|
2
|
+
|
|
3
|
+
Every command module receives `CliState` via `@click.pass_obj` and uses
|
|
4
|
+
`state.resolve_context()` / `state.connect()` to reach a device — command
|
|
5
|
+
code never touches `ContextStore` or `connect.connect()` directly, which is
|
|
6
|
+
what keeps `tests/commands/*` fast: inject a fake `MeshCoreConnection`
|
|
7
|
+
instead of calling `state.connect()`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import AsyncIterator, Callable, Coroutine
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, TypeVar
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from . import __version__, resource_specs # noqa: F401 - import registers resource specs
|
|
24
|
+
from .commands.advert import advert_command
|
|
25
|
+
from .commands.completion import completion_command
|
|
26
|
+
from .commands.config_cmd import config_group
|
|
27
|
+
from .commands.create import create_group
|
|
28
|
+
from .commands.delete import delete_group
|
|
29
|
+
from .commands.describe import describe_group
|
|
30
|
+
from .commands.exec_ import exec_command
|
|
31
|
+
from .commands.get import get_group
|
|
32
|
+
from .commands.login import login_command, logout_command
|
|
33
|
+
from .commands.logs import logs_command
|
|
34
|
+
from .commands.reboot import reboot_command
|
|
35
|
+
from .commands.scan import scan_command
|
|
36
|
+
from .commands.send import send_group
|
|
37
|
+
from .commands.set_ import set_group
|
|
38
|
+
from .commands.top import top_group
|
|
39
|
+
from .commands.trace import trace_command
|
|
40
|
+
from .commands.version import version_command
|
|
41
|
+
from .connect import ConnectError, MeshCoreConnection, connect
|
|
42
|
+
from .context_store import Context, ContextStore
|
|
43
|
+
from .mesh_data import MeshDataError
|
|
44
|
+
from .output import OutputFormat
|
|
45
|
+
|
|
46
|
+
DEFAULT_TIMEOUT = 10.0
|
|
47
|
+
|
|
48
|
+
_T = TypeVar("_T")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class CliState:
|
|
53
|
+
"""Resolved global options + the context store, threaded through every command."""
|
|
54
|
+
|
|
55
|
+
store: ContextStore
|
|
56
|
+
context_override: str | None
|
|
57
|
+
output: OutputFormat
|
|
58
|
+
timeout_override: float | None
|
|
59
|
+
verbosity: int
|
|
60
|
+
|
|
61
|
+
def resolve_context(self) -> Context:
|
|
62
|
+
"""The `Context` a command should use: `--context` override, else
|
|
63
|
+
the store's `current-context`.
|
|
64
|
+
|
|
65
|
+
Raises `click.ClickException` (not a raw store exception) since
|
|
66
|
+
this is always called from inside a command and the message is
|
|
67
|
+
meant to reach the user as-is.
|
|
68
|
+
"""
|
|
69
|
+
name = self.context_override
|
|
70
|
+
if name is None:
|
|
71
|
+
cfg = self.store.load()
|
|
72
|
+
name = cfg.current_context
|
|
73
|
+
if name is None:
|
|
74
|
+
raise click.ClickException(
|
|
75
|
+
"no context specified: pass --context NAME or run "
|
|
76
|
+
"'meshcorectl config use-context NAME' "
|
|
77
|
+
"(see 'meshcorectl config set-context --help' to create one)"
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
return self.store.get_context(name)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
raise click.ClickException(str(exc)) from exc
|
|
83
|
+
|
|
84
|
+
def effective_timeout(self, context: Context) -> float:
|
|
85
|
+
"""Precedence: `--timeout` flag > the context's own timeout > the
|
|
86
|
+
config file's `defaults.timeout` > `DEFAULT_TIMEOUT`."""
|
|
87
|
+
if self.timeout_override is not None:
|
|
88
|
+
return self.timeout_override
|
|
89
|
+
if context.timeout is not None:
|
|
90
|
+
return context.timeout
|
|
91
|
+
configured = self.store.load().defaults.get("timeout")
|
|
92
|
+
return float(configured) if configured is not None else DEFAULT_TIMEOUT
|
|
93
|
+
|
|
94
|
+
async def connect(self) -> MeshCoreConnection:
|
|
95
|
+
"""Resolve the current context and open a live connection to it."""
|
|
96
|
+
context = self.resolve_context()
|
|
97
|
+
return await connect(
|
|
98
|
+
context.connection,
|
|
99
|
+
timeout=self.effective_timeout(context),
|
|
100
|
+
debug=self.verbosity >= 2,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def run_async(self, coro: Coroutine[Any, Any, _T]) -> _T:
|
|
104
|
+
"""Convenience for commands: `state.run_async(state.connect())`."""
|
|
105
|
+
return asyncio.run(coro)
|
|
106
|
+
|
|
107
|
+
@asynccontextmanager
|
|
108
|
+
async def connected(self) -> AsyncIterator[MeshCoreConnection]:
|
|
109
|
+
"""`async with state.connected() as conn:` -- connect, yield, always
|
|
110
|
+
disconnect, even if the command body raises."""
|
|
111
|
+
connection = await self.connect()
|
|
112
|
+
try:
|
|
113
|
+
yield connection
|
|
114
|
+
finally:
|
|
115
|
+
await connection.disconnect()
|
|
116
|
+
|
|
117
|
+
def run_command(self, coro: Coroutine[Any, Any, _T]) -> _T:
|
|
118
|
+
"""Run `coro` (typically built around `async with state.connected()`),
|
|
119
|
+
translating connection/device failures into the one
|
|
120
|
+
`click.ClickException` every command surfaces -- never a raw
|
|
121
|
+
traceback from `connect.ConnectError`/`mesh_data.MeshDataError`."""
|
|
122
|
+
try:
|
|
123
|
+
return self.run_async(coro)
|
|
124
|
+
except (ConnectError, MeshDataError) as exc:
|
|
125
|
+
raise click.ClickException(str(exc)) from exc
|
|
126
|
+
|
|
127
|
+
def call(self, fn: Callable[[MeshCoreConnection], Coroutine[Any, Any, _T]]) -> _T:
|
|
128
|
+
"""The one-liner most read/write commands use: connect, call
|
|
129
|
+
`fn(connection)`, disconnect, translate errors -- e.g.
|
|
130
|
+
`state.call(fetch_contacts)`, or `state.call(lambda conn:
|
|
131
|
+
fetch_telemetry(conn, contact))` for a call needing extra args."""
|
|
132
|
+
|
|
133
|
+
async def run() -> _T:
|
|
134
|
+
async with self.connected() as connection:
|
|
135
|
+
return await fn(connection)
|
|
136
|
+
|
|
137
|
+
return self.run_command(run())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _configure_logging(verbosity: int) -> None:
|
|
141
|
+
level = logging.WARNING
|
|
142
|
+
if verbosity == 1:
|
|
143
|
+
level = logging.INFO
|
|
144
|
+
elif verbosity >= 2:
|
|
145
|
+
level = logging.DEBUG
|
|
146
|
+
logging.basicConfig(level=level, format="%(levelname)s:%(name)s:%(message)s", force=True)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@click.group(name="meshcorectl")
|
|
150
|
+
@click.option(
|
|
151
|
+
"--context",
|
|
152
|
+
"context_override",
|
|
153
|
+
default=None,
|
|
154
|
+
metavar="NAME",
|
|
155
|
+
help="Connection context to use (overrides the current context).",
|
|
156
|
+
)
|
|
157
|
+
@click.option(
|
|
158
|
+
"-o",
|
|
159
|
+
"--output",
|
|
160
|
+
"output_str",
|
|
161
|
+
type=click.Choice([f.value for f in OutputFormat]),
|
|
162
|
+
default=OutputFormat.TABLE.value,
|
|
163
|
+
show_default=True,
|
|
164
|
+
help="Output format.",
|
|
165
|
+
)
|
|
166
|
+
@click.option(
|
|
167
|
+
"--timeout",
|
|
168
|
+
"timeout_override",
|
|
169
|
+
type=float,
|
|
170
|
+
default=None,
|
|
171
|
+
metavar="SECONDS",
|
|
172
|
+
help="Per-command timeout (overrides the context/config default).",
|
|
173
|
+
)
|
|
174
|
+
@click.option(
|
|
175
|
+
"-v",
|
|
176
|
+
"--verbose",
|
|
177
|
+
"verbosity",
|
|
178
|
+
count=True,
|
|
179
|
+
help=(
|
|
180
|
+
"Increase logging verbosity (-v for info, -vv for debug). Login "
|
|
181
|
+
"passwords, device PINs, and message text are kept out of the "
|
|
182
|
+
"debug log."
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
@click.option(
|
|
186
|
+
"--config",
|
|
187
|
+
"config_path",
|
|
188
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
189
|
+
default=None,
|
|
190
|
+
metavar="PATH",
|
|
191
|
+
help="Path to the meshcorectl config file (default: ~/.config/meshcorectl/config.yaml).",
|
|
192
|
+
)
|
|
193
|
+
@click.version_option(version=__version__, prog_name="meshcorectl")
|
|
194
|
+
@click.pass_context
|
|
195
|
+
def cli(
|
|
196
|
+
ctx: click.Context,
|
|
197
|
+
context_override: str | None,
|
|
198
|
+
output_str: str,
|
|
199
|
+
timeout_override: float | None,
|
|
200
|
+
verbosity: int,
|
|
201
|
+
config_path: Path | None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""meshcorectl: a kubectl-style CLI for MeshCore companion radios."""
|
|
204
|
+
_configure_logging(verbosity)
|
|
205
|
+
ctx.obj = CliState(
|
|
206
|
+
store=ContextStore(config_path),
|
|
207
|
+
context_override=context_override,
|
|
208
|
+
output=OutputFormat(output_str),
|
|
209
|
+
timeout_override=timeout_override,
|
|
210
|
+
verbosity=verbosity,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
cli.add_command(config_group)
|
|
215
|
+
cli.add_command(get_group)
|
|
216
|
+
cli.add_command(describe_group)
|
|
217
|
+
cli.add_command(create_group)
|
|
218
|
+
cli.add_command(delete_group)
|
|
219
|
+
cli.add_command(send_group)
|
|
220
|
+
cli.add_command(exec_command)
|
|
221
|
+
cli.add_command(login_command)
|
|
222
|
+
cli.add_command(logout_command)
|
|
223
|
+
cli.add_command(top_group)
|
|
224
|
+
cli.add_command(logs_command)
|
|
225
|
+
cli.add_command(trace_command)
|
|
226
|
+
cli.add_command(advert_command)
|
|
227
|
+
cli.add_command(reboot_command)
|
|
228
|
+
cli.add_command(set_group)
|
|
229
|
+
cli.add_command(scan_command)
|
|
230
|
+
cli.add_command(version_command)
|
|
231
|
+
cli.add_command(completion_command)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> None:
|
|
235
|
+
try:
|
|
236
|
+
cli(prog_name="meshcorectl")
|
|
237
|
+
except KeyboardInterrupt:
|
|
238
|
+
click.echo("aborted", err=True)
|
|
239
|
+
sys.exit(130)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__": # pragma: no cover - exercised via the console-script entry point
|
|
243
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Click command groups, one module per verb."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""`meshcorectl advert` -- send an advertisement packet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..connect import MeshCoreConnection
|
|
10
|
+
from ..mesh_data import send_advert
|
|
11
|
+
from ..output import output_option, render_result, resolve_output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command(name="advert")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--flood", is_flag=True, help="Flood the advert instead of sending a normal (zero-hop) one."
|
|
17
|
+
)
|
|
18
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be sent without sending it.")
|
|
19
|
+
@output_option
|
|
20
|
+
@click.pass_obj
|
|
21
|
+
def advert_command(state: Any, flood: bool, dry_run: bool, output_override: str | None) -> None:
|
|
22
|
+
"""Send an advertisement packet announcing this device."""
|
|
23
|
+
if dry_run:
|
|
24
|
+
click.echo(f"would send{' a flood' if flood else ''} advert (dry run)")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
28
|
+
await send_advert(connection, flood=flood)
|
|
29
|
+
|
|
30
|
+
state.call(run)
|
|
31
|
+
text = "advert sent" + (" (flood)" if flood else "")
|
|
32
|
+
fmt = resolve_output(state.output, output_override)
|
|
33
|
+
click.echo(render_result({"sent": True, "flood": flood}, fmt, text))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""`meshcorectl completion` -- print a shell completion script.
|
|
2
|
+
|
|
3
|
+
Exposes Click's built-in completion support as a discoverable subcommand
|
|
4
|
+
(like `kubectl completion`/`gh completion`) instead of requiring the
|
|
5
|
+
`_MESHCORECTL_COMPLETE=bash_source` env-var convention.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from click.shell_completion import get_completion_class
|
|
12
|
+
|
|
13
|
+
SHELLS = ("bash", "zsh", "fish")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command(name="completion")
|
|
17
|
+
@click.argument("shell", type=click.Choice(SHELLS))
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def completion_command(ctx: click.Context, shell: str) -> None:
|
|
20
|
+
"""Print a shell completion script for SHELL.
|
|
21
|
+
|
|
22
|
+
\b
|
|
23
|
+
Load it for the current session:
|
|
24
|
+
bash: source <(meshcorectl completion bash)
|
|
25
|
+
zsh: source <(meshcorectl completion zsh)
|
|
26
|
+
fish: meshcorectl completion fish | source
|
|
27
|
+
|
|
28
|
+
\b
|
|
29
|
+
Or install it permanently, e.g. for bash:
|
|
30
|
+
meshcorectl completion bash > ~/.local/share/bash-completion/completions/meshcorectl
|
|
31
|
+
"""
|
|
32
|
+
completion_class = get_completion_class(shell)
|
|
33
|
+
if completion_class is None: # pragma: no cover - click.Choice already restricts `shell`
|
|
34
|
+
raise click.ClickException(f"unsupported shell {shell!r}")
|
|
35
|
+
|
|
36
|
+
root = ctx.find_root()
|
|
37
|
+
prog_name = root.info_name or "meshcorectl"
|
|
38
|
+
complete_var = f"_{prog_name.upper().replace('-', '_')}_COMPLETE"
|
|
39
|
+
complete = completion_class(root.command, {}, prog_name, complete_var)
|
|
40
|
+
click.echo(complete.source())
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""`meshcorectl config` — manage named connection contexts.
|
|
2
|
+
|
|
3
|
+
Deliberately mirrors `kubectl config`: `get-contexts`, `current-context`,
|
|
4
|
+
`use-context`, `set-context`, `delete-context`, `view`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from ..context_store import ConnectionSpec, ContextNotFoundError
|
|
15
|
+
from ..output import OutputFormat, output_option, resolve_output
|
|
16
|
+
from ..output.table import format_table
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(name="config")
|
|
20
|
+
def config_group() -> None:
|
|
21
|
+
"""Manage connection contexts (BLE/serial/TCP device profiles)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@config_group.command("get-contexts")
|
|
25
|
+
@click.pass_obj
|
|
26
|
+
def get_contexts(state) -> None:
|
|
27
|
+
"""List all configured contexts."""
|
|
28
|
+
cfg = state.store.load()
|
|
29
|
+
if not cfg.contexts:
|
|
30
|
+
click.echo(
|
|
31
|
+
"No contexts defined. Create one with 'meshcorectl config set-context'.", err=True
|
|
32
|
+
)
|
|
33
|
+
return
|
|
34
|
+
rows = [
|
|
35
|
+
{
|
|
36
|
+
"current": "*" if name == cfg.current_context else "",
|
|
37
|
+
"name": name,
|
|
38
|
+
"connection": context.connection.summary(),
|
|
39
|
+
"timeout": context.timeout if context.timeout is not None else "",
|
|
40
|
+
}
|
|
41
|
+
for name, context in sorted(cfg.contexts.items())
|
|
42
|
+
]
|
|
43
|
+
click.echo(
|
|
44
|
+
format_table(
|
|
45
|
+
rows,
|
|
46
|
+
[
|
|
47
|
+
("CURRENT", "current"),
|
|
48
|
+
("NAME", "name"),
|
|
49
|
+
("CONNECTION", "connection"),
|
|
50
|
+
("TIMEOUT", "timeout"),
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@config_group.command("current-context")
|
|
57
|
+
@click.pass_obj
|
|
58
|
+
def current_context(state) -> None:
|
|
59
|
+
"""Print the name of the current context."""
|
|
60
|
+
cfg = state.store.load()
|
|
61
|
+
if not cfg.current_context:
|
|
62
|
+
raise click.ClickException("current-context is not set")
|
|
63
|
+
click.echo(cfg.current_context)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@config_group.command("use-context")
|
|
67
|
+
@click.argument("name")
|
|
68
|
+
@click.pass_obj
|
|
69
|
+
def use_context(state, name: str) -> None:
|
|
70
|
+
"""Set the current context to NAME."""
|
|
71
|
+
try:
|
|
72
|
+
state.store.use_context(name)
|
|
73
|
+
except ContextNotFoundError as exc:
|
|
74
|
+
raise click.ClickException(str(exc)) from exc
|
|
75
|
+
click.echo(f'switched to context "{name}"')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@config_group.command("set-context")
|
|
79
|
+
@click.argument("name")
|
|
80
|
+
@click.option(
|
|
81
|
+
"--ble-address", default=None, metavar="ADDR", help="BLE MAC address (or UUID on macOS)."
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
"--ble-name",
|
|
85
|
+
"ble_name_filter",
|
|
86
|
+
default=None,
|
|
87
|
+
metavar="SUBSTRING",
|
|
88
|
+
help="Match a substring of the device's advertised BLE name, when the address isn't known.",
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
"--serial-port", default=None, metavar="PATH", help="Serial device, e.g. /dev/ttyUSB0."
|
|
92
|
+
)
|
|
93
|
+
@click.option("--serial-baudrate", default=115200, show_default=True, type=int)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--tcp-host", default=None, metavar="HOST", help="Hostname/IP of a TCP-exposed radio."
|
|
96
|
+
)
|
|
97
|
+
@click.option("--tcp-port", default=5000, show_default=True, type=int)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--timeout",
|
|
100
|
+
"context_timeout",
|
|
101
|
+
type=float,
|
|
102
|
+
default=None,
|
|
103
|
+
metavar="SECONDS",
|
|
104
|
+
help="Default per-command timeout for this context.",
|
|
105
|
+
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--current/--no-current",
|
|
108
|
+
"set_current",
|
|
109
|
+
default=False,
|
|
110
|
+
help="Also make this the current context (the first context you create always becomes "
|
|
111
|
+
"current).",
|
|
112
|
+
)
|
|
113
|
+
@click.pass_obj
|
|
114
|
+
def set_context(
|
|
115
|
+
state,
|
|
116
|
+
name: str,
|
|
117
|
+
ble_address: str | None,
|
|
118
|
+
ble_name_filter: str | None,
|
|
119
|
+
serial_port: str | None,
|
|
120
|
+
serial_baudrate: int,
|
|
121
|
+
tcp_host: str | None,
|
|
122
|
+
tcp_port: int,
|
|
123
|
+
context_timeout: float | None,
|
|
124
|
+
set_current: bool,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Create or update a connection context named NAME."""
|
|
127
|
+
kinds_given = {
|
|
128
|
+
"ble": ble_address is not None or ble_name_filter is not None,
|
|
129
|
+
"serial": serial_port is not None,
|
|
130
|
+
"tcp": tcp_host is not None,
|
|
131
|
+
}
|
|
132
|
+
selected = [kind for kind, given in kinds_given.items() if given]
|
|
133
|
+
if len(selected) > 1:
|
|
134
|
+
raise click.ClickException(
|
|
135
|
+
f"conflicting connection flags for: {', '.join(selected)} "
|
|
136
|
+
"(a context can only use one of --ble-*, --serial-*, or --tcp-*)"
|
|
137
|
+
)
|
|
138
|
+
if not selected:
|
|
139
|
+
raise click.ClickException(
|
|
140
|
+
"no connection specified: pass --ble-address/--ble-name, --serial-port, or --tcp-host"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
kind = selected[0]
|
|
144
|
+
if kind == "ble":
|
|
145
|
+
connection = ConnectionSpec(kind="ble", address=ble_address, name_filter=ble_name_filter)
|
|
146
|
+
elif kind == "serial":
|
|
147
|
+
connection = ConnectionSpec(kind="serial", port=serial_port, baudrate=serial_baudrate)
|
|
148
|
+
else:
|
|
149
|
+
connection = ConnectionSpec(kind="tcp", host=tcp_host, tcp_port=tcp_port)
|
|
150
|
+
|
|
151
|
+
state.store.set_context(
|
|
152
|
+
name, connection=connection, timeout=context_timeout, set_current=set_current
|
|
153
|
+
)
|
|
154
|
+
now_current = set_current or state.store.load().current_context == name
|
|
155
|
+
click.echo(f'context "{name}" set.' + (" (current)" if now_current else ""))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@config_group.command("delete-context")
|
|
159
|
+
@click.argument("name")
|
|
160
|
+
@click.pass_obj
|
|
161
|
+
def delete_context(state, name: str) -> None:
|
|
162
|
+
"""Delete context NAME."""
|
|
163
|
+
try:
|
|
164
|
+
was_current = state.store.delete_context(name)
|
|
165
|
+
except ContextNotFoundError as exc:
|
|
166
|
+
raise click.ClickException(str(exc)) from exc
|
|
167
|
+
click.echo(f'context "{name}" deleted.')
|
|
168
|
+
if was_current:
|
|
169
|
+
click.echo(
|
|
170
|
+
"note: that was the current context; set a new one with "
|
|
171
|
+
"'meshcorectl config use-context'.",
|
|
172
|
+
err=True,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@config_group.command("view")
|
|
177
|
+
@output_option
|
|
178
|
+
@click.pass_obj
|
|
179
|
+
def view(state, output_override: str | None) -> None:
|
|
180
|
+
"""Print the full contents of the config file."""
|
|
181
|
+
cfg = state.store.load()
|
|
182
|
+
fmt = resolve_output(state.output, output_override)
|
|
183
|
+
if fmt is OutputFormat.JSON:
|
|
184
|
+
click.echo(json.dumps(cfg.to_dict(), indent=2))
|
|
185
|
+
else:
|
|
186
|
+
click.echo(yaml.safe_dump(cfg.to_dict(), sort_keys=False), nl=False)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`meshcorectl create` -- import a contact card, or define a channel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..connect import MeshCoreConnection
|
|
10
|
+
from ..mesh_data import create_channel, import_contact
|
|
11
|
+
from ..output import output_option, render, render_result, resolve_output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(name="create")
|
|
15
|
+
def create_group() -> None:
|
|
16
|
+
"""Create a resource: import a contact, or define a channel."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@create_group.command("contact")
|
|
20
|
+
@click.option("--uri", required=True, metavar="URI", help="A meshcore:// contact card URI.")
|
|
21
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be imported without doing it.")
|
|
22
|
+
@output_option
|
|
23
|
+
@click.pass_obj
|
|
24
|
+
def create_contact(state: Any, uri: str, dry_run: bool, output_override: str | None) -> None:
|
|
25
|
+
"""Import a contact from its meshcore:// card URI."""
|
|
26
|
+
if dry_run:
|
|
27
|
+
click.echo(f"would import contact from {uri} (dry run)")
|
|
28
|
+
return
|
|
29
|
+
state.call(lambda conn: import_contact(conn, uri))
|
|
30
|
+
result = {"imported": True, "uri": uri}
|
|
31
|
+
fmt = resolve_output(state.output, output_override)
|
|
32
|
+
click.echo(render_result(result, fmt, f"contact imported from {uri}"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@create_group.command("channel")
|
|
36
|
+
@click.argument("index", type=int)
|
|
37
|
+
@click.argument("name")
|
|
38
|
+
@click.argument("key", required=False, metavar="[KEY]")
|
|
39
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be created without doing it.")
|
|
40
|
+
@output_option
|
|
41
|
+
@click.pass_obj
|
|
42
|
+
def create_channel_command(
|
|
43
|
+
state: Any, index: int, name: str, key: str | None, dry_run: bool, output_override: str | None
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Define channel INDEX with NAME and optional 32-hex-char KEY.
|
|
46
|
+
|
|
47
|
+
When KEY is omitted: a '#'-prefixed NAME is the public-channel convention,
|
|
48
|
+
so the device derives the same secret from NAME itself -- anyone who knows
|
|
49
|
+
the name can compute it and join. For any other NAME, meshcorectl generates
|
|
50
|
+
a random secret for you (a name-derived secret would make a "private"
|
|
51
|
+
channel just as guessable as a public one).
|
|
52
|
+
"""
|
|
53
|
+
generating = key is None and not name.startswith("#")
|
|
54
|
+
if dry_run:
|
|
55
|
+
suffix = " (auto-generating a random secret)" if generating else ""
|
|
56
|
+
click.echo(f"would create channel {index} named {name!r}{suffix} (dry run)")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
async def run(connection: MeshCoreConnection) -> dict[str, Any]:
|
|
60
|
+
return await create_channel(connection, index, name, key)
|
|
61
|
+
|
|
62
|
+
channel = state.call(run)
|
|
63
|
+
if generating:
|
|
64
|
+
click.echo(f"Generated channel secret: {channel['secret']}", err=True)
|
|
65
|
+
click.echo(render(channel, resolve_output(state.output, output_override), kind="channel"))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""`meshcorectl delete` -- remove a contact (by name or -l selector), or
|
|
2
|
+
clear a channel slot.
|
|
3
|
+
|
|
4
|
+
No `pending-contacts` subcommand: "pending" contacts are client-side
|
|
5
|
+
bookkeeping accumulated over a session (see `get.py`'s `pending-contacts`,
|
|
6
|
+
which watches for a window instead of reading a cache, for the same
|
|
7
|
+
reason). A one-shot connection never accumulates anything across
|
|
8
|
+
invocations, so there's nothing for `delete pending-contacts` to clear.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from ..connect import MeshCoreConnection
|
|
18
|
+
from ..mesh_data import (
|
|
19
|
+
AmbiguousMatchError,
|
|
20
|
+
delete_channel,
|
|
21
|
+
fetch_channels,
|
|
22
|
+
fetch_contacts,
|
|
23
|
+
find_channel,
|
|
24
|
+
find_contact,
|
|
25
|
+
)
|
|
26
|
+
from ..mesh_data import remove_contact as remove_contact_data
|
|
27
|
+
from ..selectors import SelectorError, filter_contacts
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.group(name="delete")
|
|
31
|
+
def delete_group() -> None:
|
|
32
|
+
"""Delete a contact, or clear a channel slot."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@delete_group.command("contact")
|
|
36
|
+
@click.argument("name", required=False, metavar="NAME")
|
|
37
|
+
@click.option(
|
|
38
|
+
"-l",
|
|
39
|
+
"--selector",
|
|
40
|
+
"selector_text",
|
|
41
|
+
default=None,
|
|
42
|
+
metavar="SELECTOR",
|
|
43
|
+
help="Delete every contact matching this filter instead of one by name, e.g. 't=client,u>30d'.",
|
|
44
|
+
)
|
|
45
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting it.")
|
|
46
|
+
@click.pass_obj
|
|
47
|
+
def delete_contact(state: Any, name: str | None, selector_text: str | None, dry_run: bool) -> None:
|
|
48
|
+
"""Delete one contact by NAME, or every contact matching -l/--selector."""
|
|
49
|
+
if (name is None) == (selector_text is None):
|
|
50
|
+
raise click.ClickException("pass exactly one of NAME or -l/--selector")
|
|
51
|
+
|
|
52
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
53
|
+
contacts = await fetch_contacts(connection)
|
|
54
|
+
if name is not None:
|
|
55
|
+
try:
|
|
56
|
+
contact = find_contact(contacts, name)
|
|
57
|
+
except AmbiguousMatchError as exc:
|
|
58
|
+
raise click.ClickException(str(exc)) from exc
|
|
59
|
+
if contact is None:
|
|
60
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
61
|
+
targets = [contact]
|
|
62
|
+
else:
|
|
63
|
+
try:
|
|
64
|
+
targets = filter_contacts(contacts, selector_text)
|
|
65
|
+
except SelectorError as exc:
|
|
66
|
+
raise click.ClickException(str(exc)) from exc
|
|
67
|
+
if not targets:
|
|
68
|
+
click.echo("No contacts matched.", err=True)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
for target in targets:
|
|
72
|
+
if dry_run:
|
|
73
|
+
click.echo(f"contact {target['name']!r} would be deleted (dry run)")
|
|
74
|
+
continue
|
|
75
|
+
await remove_contact_data(connection, target)
|
|
76
|
+
click.echo(f"contact {target['name']!r} deleted")
|
|
77
|
+
|
|
78
|
+
state.call(run)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@delete_group.command("channel")
|
|
82
|
+
@click.argument("index_or_name", metavar="INDEX_OR_NAME")
|
|
83
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting it.")
|
|
84
|
+
@click.pass_obj
|
|
85
|
+
def delete_channel_command(state: Any, index_or_name: str, dry_run: bool) -> None:
|
|
86
|
+
"""Clear channel INDEX_OR_NAME (there's no true delete: the name and
|
|
87
|
+
secret are cleared instead)."""
|
|
88
|
+
|
|
89
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
90
|
+
channels = await fetch_channels(connection)
|
|
91
|
+
channel = find_channel(channels, index_or_name)
|
|
92
|
+
if channel is None:
|
|
93
|
+
raise click.ClickException(f"no channel matching {index_or_name!r}")
|
|
94
|
+
if dry_run:
|
|
95
|
+
click.echo(f"channel {channel['name']!r} would be cleared (dry run)")
|
|
96
|
+
return
|
|
97
|
+
await delete_channel(connection, channel["index"])
|
|
98
|
+
click.echo(f"channel {channel['name']!r} cleared")
|
|
99
|
+
|
|
100
|
+
state.call(run)
|