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,76 @@
|
|
|
1
|
+
"""`meshcorectl scan` -- non-interactive discovery of BLE/serial candidates.
|
|
2
|
+
|
|
3
|
+
A plain read-only listing: pick an address/port from the output and pass it
|
|
4
|
+
to `meshcorectl config set-context`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import serial.tools.list_ports
|
|
13
|
+
from bleak import BleakScanner
|
|
14
|
+
from bleak.exc import BleakError
|
|
15
|
+
|
|
16
|
+
from ..output import output_option, render, resolve_output
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command(name="scan")
|
|
20
|
+
@click.option("--ble/--no-ble", "include_ble", default=True, help="Include BLE devices.")
|
|
21
|
+
@click.option(
|
|
22
|
+
"--serial/--no-serial", "include_serial", default=True, help="Include serial ports."
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--timeout",
|
|
26
|
+
"timeout",
|
|
27
|
+
type=float,
|
|
28
|
+
default=2.0,
|
|
29
|
+
show_default=True,
|
|
30
|
+
metavar="SECONDS",
|
|
31
|
+
help="BLE scan duration.",
|
|
32
|
+
)
|
|
33
|
+
@output_option
|
|
34
|
+
@click.pass_obj
|
|
35
|
+
def scan_command(
|
|
36
|
+
state: Any,
|
|
37
|
+
include_ble: bool,
|
|
38
|
+
include_serial: bool,
|
|
39
|
+
timeout: float,
|
|
40
|
+
output_override: str | None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""List discoverable BLE and serial candidates."""
|
|
43
|
+
rows = state.run_async(_scan(include_ble, include_serial, timeout))
|
|
44
|
+
if not rows:
|
|
45
|
+
click.echo("No candidates found.", err=True)
|
|
46
|
+
return
|
|
47
|
+
click.echo(render(rows, resolve_output(state.output, output_override), kind="scan-result"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _scan(include_ble: bool, include_serial: bool, timeout: float) -> list[dict[str, Any]]:
|
|
51
|
+
rows: list[dict[str, Any]] = []
|
|
52
|
+
if include_ble:
|
|
53
|
+
rows.extend(await scan_ble(timeout))
|
|
54
|
+
if include_serial:
|
|
55
|
+
rows.extend(scan_serial())
|
|
56
|
+
return rows
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def scan_ble(timeout: float) -> list[dict[str, Any]]:
|
|
60
|
+
try:
|
|
61
|
+
devices = await BleakScanner.discover(timeout=timeout)
|
|
62
|
+
except BleakError as exc:
|
|
63
|
+
click.echo(f"BLE scan unavailable: {exc}", err=True)
|
|
64
|
+
return []
|
|
65
|
+
return [
|
|
66
|
+
{"kind": "ble", "address": device.address, "name": device.name}
|
|
67
|
+
for device in devices
|
|
68
|
+
if device.name and device.name.startswith("MeshCore-")
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scan_serial() -> list[dict[str, Any]]:
|
|
73
|
+
return [
|
|
74
|
+
{"kind": "serial", "address": port.device, "name": port.description}
|
|
75
|
+
for port in serial.tools.list_ports.comports()
|
|
76
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""`meshcorectl send` -- send a message to a contact/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 (
|
|
11
|
+
AmbiguousMatchError,
|
|
12
|
+
fetch_channels,
|
|
13
|
+
fetch_contacts,
|
|
14
|
+
find_channel,
|
|
15
|
+
find_contact,
|
|
16
|
+
)
|
|
17
|
+
from ..mesh_data import send_channel_message as send_channel_message_data
|
|
18
|
+
from ..mesh_data import send_message as send_message_data
|
|
19
|
+
from ..selectors import SelectorError, filter_contacts
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group(name="send")
|
|
23
|
+
def send_group() -> None:
|
|
24
|
+
"""Send a message to a contact or a channel."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@send_group.command("message", context_settings={"ignore_unknown_options": True})
|
|
28
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED, metavar="[CONTACT] TEXT")
|
|
29
|
+
@click.option(
|
|
30
|
+
"-l",
|
|
31
|
+
"--selector",
|
|
32
|
+
"selector_text",
|
|
33
|
+
default=None,
|
|
34
|
+
metavar="SELECTOR",
|
|
35
|
+
help="Send to every contact matching this filter instead of one by name.",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--wait-ack",
|
|
39
|
+
is_flag=True,
|
|
40
|
+
help="Wait for delivery confirmation, retrying (and flooding) automatically.",
|
|
41
|
+
)
|
|
42
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be sent without sending it.")
|
|
43
|
+
@click.pass_obj
|
|
44
|
+
def send_message(
|
|
45
|
+
state: Any,
|
|
46
|
+
args: tuple[str, ...],
|
|
47
|
+
selector_text: str | None,
|
|
48
|
+
wait_ack: bool,
|
|
49
|
+
dry_run: bool,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Send TEXT to CONTACT, or to every contact matching -l/--selector.
|
|
52
|
+
|
|
53
|
+
CONTACT and TEXT come out of a single catch-all argument, not two
|
|
54
|
+
separate Click arguments: an optional leading CONTACT ahead of a
|
|
55
|
+
required TEXT has the same Click positional-filling ambiguity `exec`'s
|
|
56
|
+
docstring explains, so it's resolved the same way here. An unquoted
|
|
57
|
+
multi-word message is joined back together with spaces.
|
|
58
|
+
"""
|
|
59
|
+
tokens = list(args)
|
|
60
|
+
if selector_text is None:
|
|
61
|
+
if len(tokens) < 2:
|
|
62
|
+
raise click.ClickException(
|
|
63
|
+
"pass CONTACT and TEXT, or -l/--selector and TEXT (see --help)"
|
|
64
|
+
)
|
|
65
|
+
name: str | None = tokens[0]
|
|
66
|
+
text = " ".join(tokens[1:])
|
|
67
|
+
else:
|
|
68
|
+
if not tokens:
|
|
69
|
+
raise click.ClickException(
|
|
70
|
+
"pass CONTACT and TEXT, or -l/--selector and TEXT (see --help)"
|
|
71
|
+
)
|
|
72
|
+
name = None
|
|
73
|
+
text = " ".join(tokens)
|
|
74
|
+
|
|
75
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
76
|
+
contacts = await fetch_contacts(connection)
|
|
77
|
+
if name is not None:
|
|
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
|
+
targets = [contact]
|
|
85
|
+
else:
|
|
86
|
+
try:
|
|
87
|
+
targets = filter_contacts(contacts, selector_text)
|
|
88
|
+
except SelectorError as exc:
|
|
89
|
+
raise click.ClickException(str(exc)) from exc
|
|
90
|
+
if not targets:
|
|
91
|
+
click.echo("No contacts matched.", err=True)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
for target in targets:
|
|
95
|
+
if dry_run:
|
|
96
|
+
click.echo(f"would send to {target['name']!r}: {text!r} (dry run)")
|
|
97
|
+
continue
|
|
98
|
+
result = await send_message_data(connection, target, text, wait_ack=wait_ack)
|
|
99
|
+
status = "acked" if result.get("acked") else "sent"
|
|
100
|
+
click.echo(f"{status}: {target['name']!r}")
|
|
101
|
+
|
|
102
|
+
state.call(run)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@send_group.command("channel")
|
|
106
|
+
@click.argument("index_or_name", metavar="CHANNEL")
|
|
107
|
+
@click.argument("text")
|
|
108
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be sent without sending it.")
|
|
109
|
+
@click.pass_obj
|
|
110
|
+
def send_channel(state: Any, index_or_name: str, text: str, dry_run: bool) -> None:
|
|
111
|
+
"""Send TEXT to CHANNEL (by index or name)."""
|
|
112
|
+
|
|
113
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
114
|
+
channels = await fetch_channels(connection)
|
|
115
|
+
channel = find_channel(channels, index_or_name)
|
|
116
|
+
if channel is None:
|
|
117
|
+
raise click.ClickException(f"no channel matching {index_or_name!r}")
|
|
118
|
+
if dry_run:
|
|
119
|
+
click.echo(f"would send to channel {channel['name']!r}: {text!r} (dry run)")
|
|
120
|
+
return
|
|
121
|
+
await send_channel_message_data(connection, channel, text)
|
|
122
|
+
click.echo(f"sent: {channel['name']!r}")
|
|
123
|
+
|
|
124
|
+
state.call(run)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""`meshcorectl set device` -- set a device parameter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import textwrap
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..connect import MeshCoreConnection
|
|
12
|
+
from ..mesh_data import DEVICE_PARAMS, set_device_param
|
|
13
|
+
from ..output import output_option, render_result, resolve_output
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group(name="set")
|
|
17
|
+
def set_group() -> None:
|
|
18
|
+
"""Set a device parameter."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@set_group.command("device")
|
|
22
|
+
@click.argument("param", metavar="PARAM")
|
|
23
|
+
@click.argument("value", metavar="VALUE")
|
|
24
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be set without setting it.")
|
|
25
|
+
@output_option
|
|
26
|
+
@click.pass_obj
|
|
27
|
+
def set_device_command(
|
|
28
|
+
state: Any, param: str, value: str, dry_run: bool, output_override: str | None
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Set device parameter PARAM to VALUE.
|
|
31
|
+
|
|
32
|
+
\b
|
|
33
|
+
Known parameters: {params}
|
|
34
|
+
"""
|
|
35
|
+
if dry_run:
|
|
36
|
+
click.echo(f"would set {param} to {value!r} (dry run)")
|
|
37
|
+
return
|
|
38
|
+
if param not in DEVICE_PARAMS:
|
|
39
|
+
raise click.ClickException(
|
|
40
|
+
f"unknown device parameter {param!r} (expected one of: {', '.join(DEVICE_PARAMS)})"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def run(connection: MeshCoreConnection) -> None:
|
|
44
|
+
await set_device_param(connection, param, value)
|
|
45
|
+
|
|
46
|
+
state.call(run)
|
|
47
|
+
text = f"{param} set to {value!r}"
|
|
48
|
+
fmt = resolve_output(state.output, output_override)
|
|
49
|
+
click.echo(render_result({"param": param, "value": value}, fmt, text))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_params_help(raw_help: str, params: tuple[str, ...]) -> str:
|
|
53
|
+
"""Fill `raw_help`'s `{params}` placeholder with `params`, wrapped to a
|
|
54
|
+
readable width.
|
|
55
|
+
|
|
56
|
+
`raw_help` is dedented (`inspect.cleandoc`) *before* substituting, not
|
|
57
|
+
after: Python only auto-dedents docstrings at compile time starting in
|
|
58
|
+
3.13, so on 3.10-3.12 a bare `f.__doc__` still carries its original
|
|
59
|
+
source indentation. Substituting a multi-line, zero-indent wrapped
|
|
60
|
+
value into that indented text and dedenting afterward (which is what
|
|
61
|
+
Click's own `--help` rendering does to `.help`) breaks: the newly
|
|
62
|
+
inserted line has no indentation of its own, which drags the computed
|
|
63
|
+
common indent down to zero and defeats the dedent for every other line.
|
|
64
|
+
Dedenting first avoids the whole problem, on every Python version.
|
|
65
|
+
"""
|
|
66
|
+
wrapped = textwrap.fill(", ".join(params), width=76)
|
|
67
|
+
return inspect.cleandoc(raw_help).format(params=wrapped)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Click captures `help` from the docstring at decoration time, so `{params}`
|
|
71
|
+
# is filled in on the Command's `.help` afterward, not the original
|
|
72
|
+
# function's `__doc__`.
|
|
73
|
+
set_device_command.help = _format_params_help(set_device_command.help or "", DEVICE_PARAMS)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""`meshcorectl top` -- telemetry, the mesh analogue of `kubectl top node/pod`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from ..connect import MeshCoreConnection
|
|
11
|
+
from ..durations import parse_duration
|
|
12
|
+
from ..mesh_data import (
|
|
13
|
+
AmbiguousMatchError,
|
|
14
|
+
fetch_contacts,
|
|
15
|
+
fetch_telemetry,
|
|
16
|
+
fetch_telemetry_history,
|
|
17
|
+
find_contact,
|
|
18
|
+
)
|
|
19
|
+
from ..output import output_option, render, resolve_output
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group(name="top")
|
|
23
|
+
def top_group() -> None:
|
|
24
|
+
"""Show telemetry (instant readings, or --history min/max/avg)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@top_group.command("contact")
|
|
28
|
+
@click.argument("name")
|
|
29
|
+
@click.option(
|
|
30
|
+
"--history",
|
|
31
|
+
is_flag=True,
|
|
32
|
+
help="Show min/max/avg history instead of instant readings.",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--since",
|
|
36
|
+
"since_text",
|
|
37
|
+
default="1h",
|
|
38
|
+
show_default=True,
|
|
39
|
+
metavar="DURATION",
|
|
40
|
+
help="History window for --history, e.g. 30m, 2h, 1d.",
|
|
41
|
+
)
|
|
42
|
+
@output_option
|
|
43
|
+
@click.pass_obj
|
|
44
|
+
def top_contact(
|
|
45
|
+
state: Any, name: str, history: bool, since_text: str, output_override: str | None
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Request telemetry from a contact (typically a sensor)."""
|
|
48
|
+
start = end = 0
|
|
49
|
+
if history:
|
|
50
|
+
try:
|
|
51
|
+
duration = parse_duration(since_text)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise click.ClickException(str(exc)) from exc
|
|
54
|
+
end = int(time.time())
|
|
55
|
+
start = end - int(duration)
|
|
56
|
+
|
|
57
|
+
# Contact resolution and the telemetry fetch share one connection, like
|
|
58
|
+
# send/delete/exec/login.
|
|
59
|
+
async def run(connection: MeshCoreConnection) -> list[dict[str, Any]]:
|
|
60
|
+
contacts = await fetch_contacts(connection)
|
|
61
|
+
try:
|
|
62
|
+
contact = find_contact(contacts, name)
|
|
63
|
+
except AmbiguousMatchError as exc:
|
|
64
|
+
raise click.ClickException(str(exc)) from exc
|
|
65
|
+
if contact is None:
|
|
66
|
+
raise click.ClickException(f"no contact matching {name!r}")
|
|
67
|
+
if history:
|
|
68
|
+
return await fetch_telemetry_history(connection, contact, start=start, end=end)
|
|
69
|
+
return await fetch_telemetry(connection, contact)
|
|
70
|
+
|
|
71
|
+
readings = state.call(run)
|
|
72
|
+
kind = "telemetry-history" if history else "telemetry"
|
|
73
|
+
|
|
74
|
+
if not readings:
|
|
75
|
+
click.echo("No telemetry readings.", err=True)
|
|
76
|
+
return
|
|
77
|
+
click.echo(render(readings, resolve_output(state.output, output_override), kind=kind))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""`meshcorectl trace` -- trace the route through specific repeaters."""
|
|
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 run_trace
|
|
11
|
+
from ..output import output_option, render, resolve_output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command(name="trace")
|
|
15
|
+
@click.argument("path", metavar="PATH")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--timeout",
|
|
18
|
+
"timeout_override",
|
|
19
|
+
type=float,
|
|
20
|
+
default=None,
|
|
21
|
+
metavar="SECONDS",
|
|
22
|
+
help="Override the device-suggested wait for the trace reply.",
|
|
23
|
+
)
|
|
24
|
+
@output_option
|
|
25
|
+
@click.pass_obj
|
|
26
|
+
def trace_command(
|
|
27
|
+
state: Any, path: str, timeout_override: float | None, output_override: str | None
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Trace the route through PATH, a comma-separated list of repeater
|
|
30
|
+
public-key prefixes (e.g. "23,5f,3a")."""
|
|
31
|
+
|
|
32
|
+
async def run(connection: MeshCoreConnection) -> list[dict[str, Any]]:
|
|
33
|
+
return await run_trace(connection, path, timeout=timeout_override)
|
|
34
|
+
|
|
35
|
+
hops = state.call(run)
|
|
36
|
+
if not hops:
|
|
37
|
+
click.echo("No hops in the trace reply.", err=True)
|
|
38
|
+
return
|
|
39
|
+
click.echo(render(hops, resolve_output(state.output, output_override), kind="trace-hop"))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""`meshcorectl version` -- client (this CLI) + device firmware/protocol
|
|
2
|
+
version, mirroring `kubectl version`'s Client/Server split.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .. import __version__
|
|
14
|
+
from ..mesh_data import fetch_device
|
|
15
|
+
from ..output import OutputFormat, output_option, resolve_output
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command(name="version")
|
|
19
|
+
@click.option(
|
|
20
|
+
"--client",
|
|
21
|
+
"client_only",
|
|
22
|
+
is_flag=True,
|
|
23
|
+
help="Only print the client (this CLI's) version; don't connect to a device.",
|
|
24
|
+
)
|
|
25
|
+
@output_option
|
|
26
|
+
@click.pass_obj
|
|
27
|
+
def version_command(state: Any, client_only: bool, output_override: str | None) -> None:
|
|
28
|
+
"""Print the client version and, unless --client, the device's version."""
|
|
29
|
+
info: dict[str, Any] = {"client_version": __version__}
|
|
30
|
+
if not client_only:
|
|
31
|
+
device = state.call(fetch_device)
|
|
32
|
+
info["device"] = {
|
|
33
|
+
"name": device.get("name"),
|
|
34
|
+
"model": device.get("model"),
|
|
35
|
+
"version": device.get("ver"),
|
|
36
|
+
"protocol": device.get("fw ver"),
|
|
37
|
+
"build": device.get("fw_build"),
|
|
38
|
+
}
|
|
39
|
+
click.echo(format_version(info, resolve_output(state.output, output_override)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_version(info: dict[str, Any], fmt: OutputFormat) -> str:
|
|
43
|
+
if fmt is OutputFormat.JSON:
|
|
44
|
+
return json.dumps(info, indent=2)
|
|
45
|
+
if fmt is OutputFormat.YAML:
|
|
46
|
+
return yaml.safe_dump(info, sort_keys=False).rstrip("\n")
|
|
47
|
+
|
|
48
|
+
lines = [f"Client Version: {info['client_version']}"]
|
|
49
|
+
device = info.get("device")
|
|
50
|
+
if device is not None:
|
|
51
|
+
lines.append(f"Device Name: {device.get('name') or '-'}")
|
|
52
|
+
lines.append(f"Device Model: {device.get('model') or '-'}")
|
|
53
|
+
lines.append(
|
|
54
|
+
f"Device Version: {device.get('version') or '-'} "
|
|
55
|
+
f"(protocol {device.get('protocol') if device.get('protocol') is not None else '-'})"
|
|
56
|
+
)
|
|
57
|
+
lines.append(f"Device Build: {device.get('build') or '-'}")
|
|
58
|
+
return "\n".join(lines)
|
meshcorectl/connect.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Opens a live connection to a device described by a `ConnectionSpec`.
|
|
2
|
+
|
|
3
|
+
Command modules depend only on the `MeshCoreConnection` protocol below, not
|
|
4
|
+
on `meshcore.MeshCore` directly, so the connection method can change without
|
|
5
|
+
touching command code.
|
|
6
|
+
|
|
7
|
+
`meshcore.MeshCore.create_ble/create_serial/create_tcp` are imported inside
|
|
8
|
+
`connect()`, not at module scope, so tests can monkeypatch them without
|
|
9
|
+
importing bleak/pyserial.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from .context_store import ConnectionSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConnectError(Exception):
|
|
20
|
+
"""Raised when a connection to a device cannot be established."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class MeshCoreConnection(Protocol):
|
|
25
|
+
"""The subset of `meshcore.MeshCore` a command module is allowed to use."""
|
|
26
|
+
|
|
27
|
+
self_info: dict[str, Any]
|
|
28
|
+
commands: Any
|
|
29
|
+
|
|
30
|
+
def subscribe(self, event_type: Any, handler: Any) -> None: ... # pragma: no cover
|
|
31
|
+
|
|
32
|
+
async def disconnect(self) -> None: ... # pragma: no cover
|
|
33
|
+
|
|
34
|
+
async def wait_for_event(
|
|
35
|
+
self,
|
|
36
|
+
event_type: Any,
|
|
37
|
+
attribute_filters: dict[str, Any] | None = None,
|
|
38
|
+
timeout: float | None = None,
|
|
39
|
+
) -> Any: ... # pragma: no cover
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def connect(
|
|
43
|
+
spec: ConnectionSpec, *, timeout: float, debug: bool = False
|
|
44
|
+
) -> MeshCoreConnection:
|
|
45
|
+
"""Open a direct (v1) connection described by `spec`.
|
|
46
|
+
|
|
47
|
+
Raises `ConnectError` wrapping whatever the underlying transport raised
|
|
48
|
+
(BleakError, ConnectionError, TimeoutError, ...), so callers only need
|
|
49
|
+
to handle one exception type.
|
|
50
|
+
"""
|
|
51
|
+
from meshcore import MeshCore
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
if spec.kind == "ble":
|
|
55
|
+
client = await MeshCore.create_ble(
|
|
56
|
+
address=spec.address, debug=debug, default_timeout=timeout
|
|
57
|
+
)
|
|
58
|
+
elif spec.kind == "serial":
|
|
59
|
+
assert spec.port is not None # guaranteed by ConnectionSpec.__post_init__
|
|
60
|
+
client = await MeshCore.create_serial(
|
|
61
|
+
port=spec.port, baudrate=spec.baudrate, debug=debug, default_timeout=timeout
|
|
62
|
+
)
|
|
63
|
+
else: # tcp
|
|
64
|
+
assert spec.host is not None # guaranteed by ConnectionSpec.__post_init__
|
|
65
|
+
client = await MeshCore.create_tcp(
|
|
66
|
+
host=spec.host, port=spec.tcp_port, debug=debug, default_timeout=timeout
|
|
67
|
+
)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise ConnectError(f"failed to connect ({spec.summary()}): {exc}") from exc
|
|
70
|
+
|
|
71
|
+
# MeshCore satisfies MeshCoreConnection structurally, but self_info is a
|
|
72
|
+
# property and subscribe()'s signature is wider than this protocol
|
|
73
|
+
# exposes -- both read as mismatches to a structural type checker.
|
|
74
|
+
return cast(MeshCoreConnection, client)
|