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/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