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.
@@ -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)