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,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"))