pybricks-cli 1.0.0b1__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.
- pb_cli/__init__.py +8 -0
- pb_cli/__main__.py +7 -0
- pb_cli/app.py +127 -0
- pb_cli/banner.py +338 -0
- pb_cli/cli.py +123 -0
- pb_cli/commands/__init__.py +16 -0
- pb_cli/commands/hub.py +637 -0
- pb_cli/commands/program.py +374 -0
- pb_cli/commands/repl.py +146 -0
- pb_cli/commands/service.py +369 -0
- pb_cli/config.py +213 -0
- pb_cli/console/__init__.py +117 -0
- pb_cli/console/posix.py +125 -0
- pb_cli/console/windows.py +586 -0
- pb_cli/errors.py +680 -0
- pb_cli/escape.py +258 -0
- pb_cli/keys.py +332 -0
- pb_cli/picker.py +124 -0
- pb_cli/py.typed +0 -0
- pb_cli/session.py +813 -0
- pb_cli/ui.py +454 -0
- pybricks_cli-1.0.0b1.dist-info/METADATA +59 -0
- pybricks_cli-1.0.0b1.dist-info/RECORD +26 -0
- pybricks_cli-1.0.0b1.dist-info/WHEEL +4 -0
- pybricks_cli-1.0.0b1.dist-info/entry_points.txt +3 -0
- pybricks_cli-1.0.0b1.dist-info/licenses/LICENSE +21 -0
pb_cli/commands/hub.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Finding hubs, holding on to them, and asking what they are doing.
|
|
5
|
+
|
|
6
|
+
Each command is a thin adapter: it reads the arguments, hands the work to an
|
|
7
|
+
async body, and lets :mod:`pb_cli.errors` present whatever goes wrong. No command
|
|
8
|
+
catches its own failures, because a task group would have wrapped them in an
|
|
9
|
+
``ExceptionGroup`` anyway -- the exceptions being the few places where failing
|
|
10
|
+
is a perfectly good answer to the question asked, such as listing hubs when
|
|
11
|
+
nothing is connected.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
|
|
19
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
20
|
+
from typing import Annotated, Final
|
|
21
|
+
|
|
22
|
+
import anyio
|
|
23
|
+
import cyclopts
|
|
24
|
+
from rich.console import Console, RenderableType
|
|
25
|
+
|
|
26
|
+
import pb_client
|
|
27
|
+
from pb_cli import errors, keys, picker, ui
|
|
28
|
+
from pb_cli.app import HUB_COMMANDS, Verbose, app, configure_logging
|
|
29
|
+
from pb_cli.errors import EXIT_OK, PERMISSION_NAME
|
|
30
|
+
from pb_core.jsonrpc import ErrorCode
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"connect_cmd",
|
|
36
|
+
"disconnect_cmd",
|
|
37
|
+
"list_cmd",
|
|
38
|
+
"scan_cmd",
|
|
39
|
+
"status_cmd",
|
|
40
|
+
"use_cmd",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_ID_PREFIXES = ("ble:", "fake:", "usb:", "rfcomm:")
|
|
44
|
+
|
|
45
|
+
_HANDLED_HERE = (pb_client.PbClientError, BaseExceptionGroup)
|
|
46
|
+
"""What to catch when a command deals with a failure itself.
|
|
47
|
+
|
|
48
|
+
Catching the error's own type is not enough: anything raised inside an
|
|
49
|
+
``async with pb_client.connect()`` block comes back wrapped in a group of one,
|
|
50
|
+
so the type to catch is not the type that was raised.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_NOT_FOUND_HINTS: Final[Mapping[str | None, str]] = {
|
|
54
|
+
None: (
|
|
55
|
+
"Switch the hub on and press its button so that its light flashes. "
|
|
56
|
+
"If it is new, is the Pybricks firmware installed?"
|
|
57
|
+
),
|
|
58
|
+
"usb": (
|
|
59
|
+
"Switch the hub on and plug it in. Not every hub has a USB port: the "
|
|
60
|
+
"City, Technic and Move hubs are Bluetooth only."
|
|
61
|
+
),
|
|
62
|
+
"ble": (
|
|
63
|
+
"Switch the hub on and press its button so that its light flashes. A "
|
|
64
|
+
"hub that is already connected, or running a program, does not "
|
|
65
|
+
"advertise."
|
|
66
|
+
),
|
|
67
|
+
"rfcomm": (
|
|
68
|
+
"Pair the hub with this computer in its Bluetooth settings first. Only "
|
|
69
|
+
"the EV3 and NXT are reached this way."
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
"""What to suggest when a hub to connect to could not be found.
|
|
73
|
+
|
|
74
|
+
Keyed by the transport that was asked for, because the advice is different and
|
|
75
|
+
the wrong half of it is worse than none: telling somebody to watch for a
|
|
76
|
+
flashing light when they asked for a cable sends them to do something that
|
|
77
|
+
cannot help, and this tool's rule is that a suggestion has to be one.
|
|
78
|
+
|
|
79
|
+
Everywhere else, a missing hub means one that was expected to be connected
|
|
80
|
+
already, and the suggestion is to run [bold]pb connect[/] -- which would be
|
|
81
|
+
silly advice for [bold]pb connect[/] itself.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _connect_hints(transport: str | None) -> Mapping[int, str]:
|
|
86
|
+
"""Build the not-found advice for the transport that was asked for.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
transport: What ``--transport`` said, or ``None`` for any.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The hints to hand to the error renderer.
|
|
93
|
+
"""
|
|
94
|
+
hint = _NOT_FOUND_HINTS.get(transport, _NOT_FOUND_HINTS[None])
|
|
95
|
+
|
|
96
|
+
return {ErrorCode.HUB_NOT_FOUND: hint}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _asked(platform: str = sys.platform) -> str:
|
|
100
|
+
"""Say that the system is asking whether the service may use Bluetooth.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
platform: What to say it for. Only macOS asks, today.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The sentence, with markup.
|
|
107
|
+
"""
|
|
108
|
+
who = "macOS" if platform == "darwin" else "The system"
|
|
109
|
+
return (
|
|
110
|
+
f"{who} is asking whether [bold]{PERMISSION_NAME}[/] may use "
|
|
111
|
+
"Bluetooth. Answer the dialog to carry on."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@asynccontextmanager
|
|
116
|
+
async def _telling_when_asked(
|
|
117
|
+
service: pb_client.Service, say: Callable[[bool], None]
|
|
118
|
+
) -> AsyncGenerator[None, None]:
|
|
119
|
+
"""Say when the system starts or stops asking for Bluetooth, while hubs are sought.
|
|
120
|
+
|
|
121
|
+
Open it before the scan or connection it is for, so that nothing it reports
|
|
122
|
+
is missed. The search itself carries on once the user answers, so this only
|
|
123
|
+
has to tell them to; what changes is whether they look for the dialog now
|
|
124
|
+
or are told to answer it once the search has given up.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
service: Where the search runs.
|
|
128
|
+
say: Called with True when the system starts asking, and with False if
|
|
129
|
+
it stops before the search ends.
|
|
130
|
+
|
|
131
|
+
Yields:
|
|
132
|
+
Nothing. The block is the search.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
async def follow(
|
|
136
|
+
changes: AsyncIterator[tuple[pb_client.UnusableAdapter, ...]],
|
|
137
|
+
) -> None:
|
|
138
|
+
asked = False
|
|
139
|
+
|
|
140
|
+
async for unusable in changes:
|
|
141
|
+
now = any(adapter.awaiting_permission for adapter in unusable)
|
|
142
|
+
|
|
143
|
+
if now != asked:
|
|
144
|
+
asked = now
|
|
145
|
+
say(asked)
|
|
146
|
+
|
|
147
|
+
async with service.watching_adapters() as changes, anyio.create_task_group() as tg:
|
|
148
|
+
tg.start_soon(follow, changes)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
yield
|
|
152
|
+
finally:
|
|
153
|
+
tg.cancel_scope.cancel()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _looks_like_id(value: str) -> bool:
|
|
157
|
+
"""Whether a string is a hub id rather than a hub name."""
|
|
158
|
+
return value.startswith(_ID_PREFIXES)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command(name="scan", group=HUB_COMMANDS, sort_key=5)
|
|
162
|
+
async def scan_cmd(
|
|
163
|
+
*,
|
|
164
|
+
# ASYNC109 catches a timeout threaded into an async API, and this is the
|
|
165
|
+
# exception it cannot know about: the name is the spelling of a
|
|
166
|
+
# command-line option, not a deadline. It says how long the radio should
|
|
167
|
+
# listen, which is a real parameter of scanning, and reaches pb-client as
|
|
168
|
+
# `seconds`. A caller wanting to bound its own wait still opens a cancel
|
|
169
|
+
# scope, as the house rules require.
|
|
170
|
+
timeout: float = 10.0, # noqa: ASYNC109
|
|
171
|
+
name: str | None = None,
|
|
172
|
+
transport: str | None = None,
|
|
173
|
+
verbose: Verbose = (),
|
|
174
|
+
) -> int:
|
|
175
|
+
"""Look for Pybricks hubs that are switched on and in range.
|
|
176
|
+
|
|
177
|
+
Nothing is connected to, which is the point: this answers "is it on, and
|
|
178
|
+
can I hear it?" without taking the hub away from whatever else might want
|
|
179
|
+
it.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
timeout: How long to scan, in seconds.
|
|
183
|
+
name: Only show hubs with this name.
|
|
184
|
+
transport: Only look on this transport: usb, ble or rfcomm. All of
|
|
185
|
+
them by default.
|
|
186
|
+
verbose: Increase log detail.
|
|
187
|
+
"""
|
|
188
|
+
configure_logging(verbose)
|
|
189
|
+
return await errors.run(_scan, timeout, name, transport)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _scan(seconds: float, name: str | None, transport: str | None) -> int:
|
|
193
|
+
out, err = ui.console(), ui.console(err=True)
|
|
194
|
+
seen: dict[str, pb_client.DeviceInfo] = {}
|
|
195
|
+
caption = f"Scanning for {seconds:.0f}s..."
|
|
196
|
+
|
|
197
|
+
def scanning(
|
|
198
|
+
service: pb_client.Service,
|
|
199
|
+
) -> AbstractAsyncContextManager[AsyncIterator[pb_client.DeviceInfo]]:
|
|
200
|
+
return service.scanning(seconds=seconds, name=name, transport=transport)
|
|
201
|
+
|
|
202
|
+
async with pb_client.connect() as service:
|
|
203
|
+
if out.is_terminal:
|
|
204
|
+
# Redrawn in place as advertisements arrive, so a hub appears the
|
|
205
|
+
# moment it is heard and its signal moves as it is carried about.
|
|
206
|
+
with ui.live(ui.scan_table((), caption), out) as display:
|
|
207
|
+
|
|
208
|
+
def say(asked: bool) -> None:
|
|
209
|
+
# In the caption, not printed: this display is on stdout,
|
|
210
|
+
# and a line on stderr would land in the middle of it.
|
|
211
|
+
nonlocal caption
|
|
212
|
+
caption = _asked() if asked else f"Scanning for {seconds:.0f}s..."
|
|
213
|
+
display.update(ui.scan_table(seen.values(), caption), refresh=True)
|
|
214
|
+
|
|
215
|
+
async with (
|
|
216
|
+
_telling_when_asked(service, say),
|
|
217
|
+
scanning(service) as devices,
|
|
218
|
+
):
|
|
219
|
+
async for device in devices:
|
|
220
|
+
seen[device.id] = device
|
|
221
|
+
display.update(
|
|
222
|
+
ui.scan_table(seen.values(), caption), refresh=True
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
display.update(ui.scan_table(seen.values(), f"Found {len(seen)}."))
|
|
226
|
+
else:
|
|
227
|
+
|
|
228
|
+
def tell(asked: bool) -> None:
|
|
229
|
+
if asked:
|
|
230
|
+
err.print(_asked())
|
|
231
|
+
|
|
232
|
+
async with _telling_when_asked(service, tell), scanning(service) as devices:
|
|
233
|
+
# Piped: one line per hub, printed the moment it is first
|
|
234
|
+
# heard, so that "pb scan | head -1" answers as soon as there
|
|
235
|
+
# is an answer.
|
|
236
|
+
async for device in devices:
|
|
237
|
+
if device.id not in seen:
|
|
238
|
+
line = (
|
|
239
|
+
f"{device.name}\t{device.id}"
|
|
240
|
+
if ui.SHOW_HUB_IDS
|
|
241
|
+
else device.name
|
|
242
|
+
)
|
|
243
|
+
ui.plain(out, line)
|
|
244
|
+
|
|
245
|
+
seen[device.id] = device
|
|
246
|
+
|
|
247
|
+
if not seen:
|
|
248
|
+
if transport == "rfcomm":
|
|
249
|
+
# The listing is the paired set, so an empty one means nothing is
|
|
250
|
+
# paired. Switching the hub on would not change it.
|
|
251
|
+
err.print("[yellow]No hubs found.[/] Is the hub paired with this computer?")
|
|
252
|
+
else:
|
|
253
|
+
# "In range" is Bluetooth's word for it and means nothing to
|
|
254
|
+
# somebody who asked about a cable.
|
|
255
|
+
where = "plugged in" if transport == "usb" else "in range"
|
|
256
|
+
err.print(f"[yellow]No hubs found.[/] Is the hub switched on and {where}?")
|
|
257
|
+
|
|
258
|
+
return EXIT_OK
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.command(name="connect", group=HUB_COMMANDS, sort_key=0)
|
|
262
|
+
async def connect_cmd(
|
|
263
|
+
*,
|
|
264
|
+
hub: str | None = None,
|
|
265
|
+
scan: Annotated[bool, cyclopts.Parameter(name=["-s", "--scan"])] = False,
|
|
266
|
+
transport: str | None = None,
|
|
267
|
+
verbose: Verbose = (),
|
|
268
|
+
) -> int:
|
|
269
|
+
"""Connect to a hub and keep it connected.
|
|
270
|
+
|
|
271
|
+
The connection outlives this command, so later commands reuse it rather
|
|
272
|
+
than reconnecting.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
hub: The hub's name, as shown by pb scan. Defaults to the first
|
|
276
|
+
hub found.
|
|
277
|
+
scan: Watch for hubs and choose one from the list. There is no time
|
|
278
|
+
limit, so the hub can be switched on whenever you like, and
|
|
279
|
+
Ctrl-C gives up without connecting to anything.
|
|
280
|
+
transport: Reach the hub this way: usb, ble or rfcomm. By default
|
|
281
|
+
whichever works, preferring USB where a hub is reachable more than
|
|
282
|
+
one way.
|
|
283
|
+
verbose: Increase log detail.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
if scan:
|
|
287
|
+
# --scan wins over a named hub rather than being refused
|
|
288
|
+
# alongside one. The name may have come from the environment or
|
|
289
|
+
# from the project's pyproject.toml, and refusing a value the
|
|
290
|
+
# user never typed would make --scan unusable inside a project.
|
|
291
|
+
hub = None
|
|
292
|
+
|
|
293
|
+
if scan and not keys.keys_available():
|
|
294
|
+
# Better to say so than to sit waiting for a keystroke that cannot
|
|
295
|
+
# arrive, which is what a piped or emulated terminal would do.
|
|
296
|
+
return ui.usage(
|
|
297
|
+
"Choosing a hub needs a terminal to read the arrow keys from.",
|
|
298
|
+
"Name the hub with [bold]--hub[/] instead, or run "
|
|
299
|
+
"[bold]pb scan[/] to see what is nearby.",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
configure_logging(verbose)
|
|
303
|
+
return await errors.run(
|
|
304
|
+
_connect, hub, scan, transport, hints=_connect_hints(transport)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _connect(hub: str | None, scan: bool, transport: str | None) -> int:
|
|
309
|
+
err = ui.console(err=True)
|
|
310
|
+
|
|
311
|
+
def tell(asked: bool) -> None:
|
|
312
|
+
# Printed above the spinner or the chooser, both of which are drawn
|
|
313
|
+
# on this console and so make room for it.
|
|
314
|
+
if asked:
|
|
315
|
+
err.print(_asked())
|
|
316
|
+
|
|
317
|
+
async with pb_client.connect() as service:
|
|
318
|
+
if scan:
|
|
319
|
+
# No time limit: the hub may still be in the next room. The scan
|
|
320
|
+
# ends when the block does, whether the user chose or gave up.
|
|
321
|
+
async with (
|
|
322
|
+
_telling_when_asked(service, tell),
|
|
323
|
+
service.scanning(seconds=None, transport=transport) as devices,
|
|
324
|
+
):
|
|
325
|
+
device = await picker.choose_hub(err, devices, ui.chooser_table)
|
|
326
|
+
|
|
327
|
+
if device is None:
|
|
328
|
+
# Deciding not to connect is what was asked for, not a failure.
|
|
329
|
+
err.print("No hub chosen.")
|
|
330
|
+
return EXIT_OK
|
|
331
|
+
|
|
332
|
+
with ui.spinner(err, "Connecting..."):
|
|
333
|
+
info = await service.connect_hub(hub_id=device.id)
|
|
334
|
+
# No transport here: choosing a row from the list has already
|
|
335
|
+
# chosen the route, since each row is one.
|
|
336
|
+
else:
|
|
337
|
+
with ui.spinner(err, "Connecting..."):
|
|
338
|
+
async with _telling_when_asked(service, tell):
|
|
339
|
+
info = await service.connect_hub(
|
|
340
|
+
hub_id=hub if hub and _looks_like_id(hub) else None,
|
|
341
|
+
name=hub if hub and not _looks_like_id(hub) else None,
|
|
342
|
+
transport=transport,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# The protocol version belongs to pb status: it explains why a command was
|
|
346
|
+
# refused, but on a successful connection it is one more number to read
|
|
347
|
+
# past.
|
|
348
|
+
described = f"firmware {info.firmware}"
|
|
349
|
+
|
|
350
|
+
if info.hub_type:
|
|
351
|
+
described = f"{info.hub_type}, {described}"
|
|
352
|
+
|
|
353
|
+
err.print(f"Connected to [bold]{info.name}[/] ({described}).")
|
|
354
|
+
return EXIT_OK
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@app.command(name="disconnect", group=HUB_COMMANDS, sort_key=1)
|
|
358
|
+
async def disconnect_cmd(*, hub: str | None = None, verbose: Verbose = ()) -> int:
|
|
359
|
+
"""Disconnect a hub.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
hub: Which hub, by name. Defaults to the one in use.
|
|
363
|
+
verbose: Increase log detail.
|
|
364
|
+
"""
|
|
365
|
+
configure_logging(verbose)
|
|
366
|
+
return await errors.run(_disconnect, hub)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def _disconnect(hub: str | None) -> int:
|
|
370
|
+
err = ui.console(err=True)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
async with pb_client.connect(autostart=False) as service:
|
|
374
|
+
await service.disconnect_hub(hub)
|
|
375
|
+
except _HANDLED_HERE as exc:
|
|
376
|
+
# Nothing connected means there is nothing to disconnect, which is the
|
|
377
|
+
# outcome the user asked for rather than a failure.
|
|
378
|
+
if errors.find(exc, pb_client.ServiceUnavailable) is None:
|
|
379
|
+
raise
|
|
380
|
+
|
|
381
|
+
err.print("No hub is connected.")
|
|
382
|
+
return EXIT_OK
|
|
383
|
+
|
|
384
|
+
err.print("Disconnected.")
|
|
385
|
+
return EXIT_OK
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@app.command(name="list", group=HUB_COMMANDS, sort_key=3)
|
|
389
|
+
async def list_cmd(*, verbose: Verbose = ()) -> int:
|
|
390
|
+
"""List the hubs that are currently connected.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
verbose: Increase log detail.
|
|
394
|
+
"""
|
|
395
|
+
configure_logging(verbose)
|
|
396
|
+
return await errors.run(_list)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
async def _list() -> int:
|
|
400
|
+
out, err = ui.console(), ui.console(err=True)
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
async with pb_client.connect(autostart=False) as service:
|
|
404
|
+
hubs, acting = await service.list_hubs()
|
|
405
|
+
except _HANDLED_HERE as exc:
|
|
406
|
+
# An empty list is a real answer to "what is connected?".
|
|
407
|
+
if errors.find(exc, pb_client.ServiceUnavailable) is None:
|
|
408
|
+
raise
|
|
409
|
+
|
|
410
|
+
err.print("No hubs are connected.")
|
|
411
|
+
return EXIT_OK
|
|
412
|
+
|
|
413
|
+
if not hubs:
|
|
414
|
+
err.print("No hubs connected. Use [bold]pb connect[/].")
|
|
415
|
+
return EXIT_OK
|
|
416
|
+
|
|
417
|
+
if out.is_terminal:
|
|
418
|
+
out.print(ui.connected_table(hubs, acting))
|
|
419
|
+
else:
|
|
420
|
+
for info in hubs:
|
|
421
|
+
marker = "*" if info.id == acting else "-"
|
|
422
|
+
fields = [marker, info.name, info.id, info.firmware]
|
|
423
|
+
|
|
424
|
+
if not ui.SHOW_HUB_IDS:
|
|
425
|
+
del fields[2]
|
|
426
|
+
|
|
427
|
+
ui.plain(out, "\t".join(fields))
|
|
428
|
+
|
|
429
|
+
return EXIT_OK
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@app.command(name="use", group=HUB_COMMANDS, sort_key=4)
|
|
433
|
+
async def use_cmd(
|
|
434
|
+
*,
|
|
435
|
+
hub: str | None = None,
|
|
436
|
+
clear: bool = False,
|
|
437
|
+
verbose: Verbose = (),
|
|
438
|
+
) -> int:
|
|
439
|
+
"""Choose the hub that commands act on when none is named.
|
|
440
|
+
|
|
441
|
+
With no options, this lists the hubs that are connected and the hubs it
|
|
442
|
+
can hear, and lets you choose one with the arrow keys.
|
|
443
|
+
|
|
444
|
+
What is remembered is the hub's name, so the choice survives the hub being
|
|
445
|
+
switched off and on, and outlives the background service. Every shell sees
|
|
446
|
+
the same choice.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
hub: The hub's name. It does not have to be connected, or even switched
|
|
450
|
+
on, so the hub a project belongs to can be chosen before any of it
|
|
451
|
+
is plugged in.
|
|
452
|
+
clear: Forget the chosen hub. Commands then act on whichever hub is
|
|
453
|
+
connected, as long as there is only one.
|
|
454
|
+
verbose: Increase log detail.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
if clear:
|
|
458
|
+
# --clear wins over a named hub, for the same reason --scan does
|
|
459
|
+
# on connect: the name may have come from a project file rather
|
|
460
|
+
# than from the person typing, and forgetting the chosen hub
|
|
461
|
+
# would otherwise be impossible in a project directory.
|
|
462
|
+
hub = None
|
|
463
|
+
|
|
464
|
+
configure_logging(verbose)
|
|
465
|
+
return await errors.run(_use, hub, clear)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def _use(hub: str | None, clear: bool) -> int:
|
|
469
|
+
err = ui.console(err=True)
|
|
470
|
+
|
|
471
|
+
async with pb_client.connect() as service:
|
|
472
|
+
if clear:
|
|
473
|
+
await service.use_hub(None)
|
|
474
|
+
err.print(
|
|
475
|
+
"No hub is chosen. Commands will use the connected hub, "
|
|
476
|
+
"while there is only one."
|
|
477
|
+
)
|
|
478
|
+
return EXIT_OK
|
|
479
|
+
|
|
480
|
+
if hub is None:
|
|
481
|
+
if not keys.keys_available():
|
|
482
|
+
# Nothing can be chosen without a terminal to choose in, so
|
|
483
|
+
# answer the other question this command can answer: which hub
|
|
484
|
+
# is chosen already.
|
|
485
|
+
return await _say_which(service, err)
|
|
486
|
+
|
|
487
|
+
hub = await _choose(service, err)
|
|
488
|
+
if hub is None:
|
|
489
|
+
err.print("Nothing chosen.")
|
|
490
|
+
return EXIT_OK
|
|
491
|
+
|
|
492
|
+
choice = await service.use_hub(hub)
|
|
493
|
+
err.print(f"Now using [bold]{choice.name}[/].")
|
|
494
|
+
|
|
495
|
+
if not choice.connected:
|
|
496
|
+
# Worth saying, because nothing else will: commands go on using
|
|
497
|
+
# the hub that is connected until this one is.
|
|
498
|
+
err.print("It is not connected at the moment.")
|
|
499
|
+
|
|
500
|
+
return EXIT_OK
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
async def _say_which(service: pb_client.Service, err: Console) -> int:
|
|
504
|
+
"""Report the chosen hub, for a caller with no terminal to choose in.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
service: The connected service.
|
|
508
|
+
err: Where messages go.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
The exit code. Nothing chosen is an answer rather than a failure, so
|
|
512
|
+
it is still zero; the name goes to stdout, where a script reading it
|
|
513
|
+
gets the name and nothing else.
|
|
514
|
+
"""
|
|
515
|
+
choice = await service.chosen_hub()
|
|
516
|
+
if choice.name is None:
|
|
517
|
+
err.print("No hub is chosen.")
|
|
518
|
+
return EXIT_OK
|
|
519
|
+
|
|
520
|
+
ui.plain(ui.console(), choice.name)
|
|
521
|
+
return EXIT_OK
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
async def _choose(service: pb_client.Service, err: Console) -> str | None:
|
|
525
|
+
"""Let the user pick from the hubs that are connected or in range.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
service: The connected service.
|
|
529
|
+
err: Where the list is drawn.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
The chosen hub's name, or None if the user left without choosing.
|
|
533
|
+
"""
|
|
534
|
+
hubs, _ = await service.list_hubs()
|
|
535
|
+
connected = [
|
|
536
|
+
pb_client.DeviceInfo(
|
|
537
|
+
id=info.id,
|
|
538
|
+
name=info.name,
|
|
539
|
+
address=info.address,
|
|
540
|
+
rssi=None,
|
|
541
|
+
transport=info.id.split(":", 1)[0],
|
|
542
|
+
hub_type=info.hub_type,
|
|
543
|
+
)
|
|
544
|
+
for info in hubs
|
|
545
|
+
]
|
|
546
|
+
known = {device.id for device in connected}
|
|
547
|
+
|
|
548
|
+
async def offered() -> AsyncIterator[pb_client.DeviceInfo]:
|
|
549
|
+
"""Offer the connected hubs first, then whatever the radio hears."""
|
|
550
|
+
|
|
551
|
+
for device in connected:
|
|
552
|
+
yield device
|
|
553
|
+
|
|
554
|
+
async for device in found:
|
|
555
|
+
# A connected hub does not advertise, so this is belt and braces:
|
|
556
|
+
# what it prevents is a hub arriving twice under two descriptions.
|
|
557
|
+
if device.id not in known:
|
|
558
|
+
yield device
|
|
559
|
+
|
|
560
|
+
def render(devices: list[pb_client.DeviceInfo], selected: int) -> RenderableType:
|
|
561
|
+
return ui.chooser_table(devices, selected, connected=known)
|
|
562
|
+
|
|
563
|
+
# No time limit: the hub may still be in the next room.
|
|
564
|
+
async with service.scanning(seconds=None) as found:
|
|
565
|
+
device = await picker.choose_hub(err, offered(), render, verb="use it")
|
|
566
|
+
|
|
567
|
+
return device.name if device is not None else None
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@app.command(name="status", group=HUB_COMMANDS, sort_key=2)
|
|
571
|
+
async def status_cmd(
|
|
572
|
+
*, hub: str | None = None, full: bool = False, verbose: Verbose = ()
|
|
573
|
+
) -> int:
|
|
574
|
+
"""Show what a hub is doing.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
hub: Which hub, by name. Defaults to the one in use.
|
|
578
|
+
full: Show every detail, including the ones that only mean something
|
|
579
|
+
when you are debugging.
|
|
580
|
+
verbose: Increase log detail.
|
|
581
|
+
"""
|
|
582
|
+
configure_logging(verbose)
|
|
583
|
+
return await errors.run(_status, hub, full)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def _status(hub: str | None, full: bool) -> int:
|
|
587
|
+
out = ui.console()
|
|
588
|
+
|
|
589
|
+
async with pb_client.connect(autostart=False) as service:
|
|
590
|
+
info = await service.hub_info(hub)
|
|
591
|
+
|
|
592
|
+
rows = [("name", info.name)]
|
|
593
|
+
|
|
594
|
+
if ui.SHOW_HUB_IDS:
|
|
595
|
+
rows.append(("id", info.id))
|
|
596
|
+
|
|
597
|
+
rows.append(("state", info.state))
|
|
598
|
+
|
|
599
|
+
# Which way the hub is reached, because it decides how quick a command is
|
|
600
|
+
# and, over USB, whether the hub is still advertising for anything else to
|
|
601
|
+
# find. Left out entirely when the service did not say, rather than shown
|
|
602
|
+
# blank.
|
|
603
|
+
connection = ui.transport_name(info.transport)
|
|
604
|
+
if connection:
|
|
605
|
+
rows.append(("connection", connection))
|
|
606
|
+
|
|
607
|
+
if info.hub_type:
|
|
608
|
+
rows.append(("type", info.hub_type))
|
|
609
|
+
|
|
610
|
+
rows.append(("firmware", info.firmware))
|
|
611
|
+
|
|
612
|
+
if full:
|
|
613
|
+
rows.append(("protocol", info.protocol))
|
|
614
|
+
|
|
615
|
+
# Firmware older than the multi-slot support reports none, and a hub
|
|
616
|
+
# with zero program slots would be a stranger thing to read than no
|
|
617
|
+
# line at all.
|
|
618
|
+
if info.num_slots:
|
|
619
|
+
rows.append(("slots", str(info.num_slots)))
|
|
620
|
+
|
|
621
|
+
rows.append(("program", "running" if info.program_running else "stopped"))
|
|
622
|
+
|
|
623
|
+
if full:
|
|
624
|
+
rows.append(("used by", str(info.subscribers)))
|
|
625
|
+
|
|
626
|
+
# The raw flag names are the firmware's vocabulary, not the user's. The few
|
|
627
|
+
# that say something actionable are worth translating; the rest are only
|
|
628
|
+
# interesting when something is being debugged, which is what --full is.
|
|
629
|
+
warnings = ui.hub_warnings(info.status_names)
|
|
630
|
+
if warnings:
|
|
631
|
+
rows.append(("warnings", ", ".join(warnings)))
|
|
632
|
+
|
|
633
|
+
if full and info.status_names:
|
|
634
|
+
rows.append(("flags", ", ".join(info.status_names)))
|
|
635
|
+
|
|
636
|
+
ui.rows(out, rows)
|
|
637
|
+
return EXIT_OK
|