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/ui.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""What the commands put on screen.
|
|
5
|
+
|
|
6
|
+
Everything a command draws goes through here, for two reasons. Commands stay
|
|
7
|
+
thin, which is what keeps them readable as "ask the service, then say what
|
|
8
|
+
happened"; and the whole of the tool's appearance can be redirected at once,
|
|
9
|
+
which is how :mod:`tools.screenshot` records a real session for the README.
|
|
10
|
+
|
|
11
|
+
One rule governs all of it. **Data goes to stdout and everything else goes to
|
|
12
|
+
stderr** -- progress, spinners, tables meant to be looked at rather than
|
|
13
|
+
parsed, prompts, errors. That single split is what keeps ``pb list | jq`` and
|
|
14
|
+
``pb scan > hubs.txt`` working, and it is why :func:`console` takes ``err``
|
|
15
|
+
rather than being one console everything shares.
|
|
16
|
+
|
|
17
|
+
The second rule is that anything drawn with colour must also read without it.
|
|
18
|
+
A terminal that strips styling, a log file, a reader who cannot tell green from
|
|
19
|
+
red: none of them should lose information. So the signal meter varies its
|
|
20
|
+
characters and not only its colour, and the chooser marks its selected row with
|
|
21
|
+
an arrow as well as a style.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
from collections.abc import Collection, Generator, Iterable, Mapping
|
|
28
|
+
from contextlib import AbstractContextManager
|
|
29
|
+
from typing import Final
|
|
30
|
+
|
|
31
|
+
from rich.console import Console, RenderableType
|
|
32
|
+
from rich.live import Live
|
|
33
|
+
from rich.table import Table
|
|
34
|
+
from rich.text import Text
|
|
35
|
+
|
|
36
|
+
import pb_client
|
|
37
|
+
from pb_cli import errors
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"HUB_WARNINGS",
|
|
41
|
+
"SELECTED_MARK",
|
|
42
|
+
"SHOW_HUB_IDS",
|
|
43
|
+
"SIGNAL_BLOCKS",
|
|
44
|
+
"SIGNAL_EMPTY",
|
|
45
|
+
"chooser_table",
|
|
46
|
+
"connected_table",
|
|
47
|
+
"console",
|
|
48
|
+
"hub_warnings",
|
|
49
|
+
"live",
|
|
50
|
+
"plain",
|
|
51
|
+
"rows",
|
|
52
|
+
"scan_table",
|
|
53
|
+
"signal_bars",
|
|
54
|
+
"spinner",
|
|
55
|
+
"usage",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
SHOW_HUB_IDS: bool = False
|
|
59
|
+
"""Whether to print hub ids.
|
|
60
|
+
|
|
61
|
+
Off for now. A hub id is built from its Bluetooth address, which is not stable
|
|
62
|
+
-- and on macOS is a per-machine UUID standing in for one -- so printing it
|
|
63
|
+
invites users to write down something that will stop working. Ids remain in the
|
|
64
|
+
wire protocol and are still accepted as arguments; only the display is off.
|
|
65
|
+
|
|
66
|
+
Turn this on once a hub has a durable identifier worth showing -- see "Wanted
|
|
67
|
+
from the firmware" in the design notes, since the hub does not have one yet.
|
|
68
|
+
Annotated as ``bool`` rather than left to inference so that the code behind it
|
|
69
|
+
keeps being type-checked while it is switched off.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
SIGNAL_BLOCKS = "▁▃▅▇"
|
|
73
|
+
"""The signal meter, one character per level, rising left to right."""
|
|
74
|
+
|
|
75
|
+
SIGNAL_EMPTY = "·"
|
|
76
|
+
"""What stands in for a bar that is not lit.
|
|
77
|
+
|
|
78
|
+
A dim copy of the block would say the same thing in colour alone, which is no
|
|
79
|
+
use to a colour-blind reader or to anything that strips styling. A dot is
|
|
80
|
+
plainly not a bar.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
SELECTED_MARK = "▸"
|
|
84
|
+
"""Marks the row a chooser is on, so the selection reads without colour."""
|
|
85
|
+
|
|
86
|
+
_SIGNAL_FLOORS = (-90, -80, -67, -55)
|
|
87
|
+
"""How many dBm each level needs, weakest first.
|
|
88
|
+
|
|
89
|
+
Bluetooth reports signal strength as a negative number of dBm, roughly -30 for
|
|
90
|
+
a hub on the desk down to about -100 for one that can barely be heard. The
|
|
91
|
+
boundaries are the ones Wi-Fi tools have long used, which put a hub in the same
|
|
92
|
+
room at four bars and one through a wall at two. Anything at or below the first
|
|
93
|
+
floor still gets one bar rather than none: it was heard, so it is there.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
HUB_WARNINGS: Final[Mapping[str, str]] = {
|
|
97
|
+
"BATTERY_LOW_VOLTAGE_WARNING": "battery low",
|
|
98
|
+
"BATTERY_LOW_VOLTAGE_SHUTDOWN": "battery empty, the hub is switching off",
|
|
99
|
+
"BATTERY_HIGH_CURRENT": "drawing too much current",
|
|
100
|
+
"BATTERY_HIGH_TEMP_WARNING": "battery hot",
|
|
101
|
+
"BATTERY_HIGH_TEMP_SHUTDOWN": "battery too hot, the hub is switching off",
|
|
102
|
+
"BLE_LOW_SIGNAL": "weak Bluetooth signal",
|
|
103
|
+
"SHUTDOWN_REQUEST": "switching off",
|
|
104
|
+
"SHUTDOWN": "switching off",
|
|
105
|
+
}
|
|
106
|
+
"""Hub status flags that are worth saying out loud, in the user's words.
|
|
107
|
+
|
|
108
|
+
Keyed on the wire name of the flag rather than on the bit, because the CLI
|
|
109
|
+
deliberately knows nothing about the hub protocol. Flags that describe a normal
|
|
110
|
+
state -- advertising, connected, a program running -- are missing on purpose:
|
|
111
|
+
they are either uninteresting or already a line of their own.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def console(*, err: bool = False) -> Console:
|
|
116
|
+
"""Build a console for data (stdout) or for everything else (stderr).
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
err: True for the console that progress, prompts and messages go to.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A fresh console. Fresh rather than shared so that a caller can be
|
|
123
|
+
given a different one -- which is how the session in the README is
|
|
124
|
+
recorded.
|
|
125
|
+
"""
|
|
126
|
+
# Highlighting off. Rich's default highlighter reads a string as a Python
|
|
127
|
+
# repr and colours what it finds there, which is right for a value dumped
|
|
128
|
+
# to a terminal and wrong for a sentence: "firmware 4.1.0b3-134-gb4bd99edd"
|
|
129
|
+
# came out with "4.1" and "134" in cyan and everything between them plain,
|
|
130
|
+
# as though the version were two numbers with debris around them, and every
|
|
131
|
+
# bracketed aside was drawn in bold. Nothing printed here is a repr -- data
|
|
132
|
+
# goes to stdout through `plain` and `rows`, which do not use print at all
|
|
133
|
+
# -- so any emphasis in this output should be markup we chose.
|
|
134
|
+
return Console(stderr=err, highlight=False)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@contextlib.contextmanager
|
|
138
|
+
def _null() -> Generator[None]:
|
|
139
|
+
"""Stand in for a spinner when output is not a terminal."""
|
|
140
|
+
yield
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def spinner(err: Console, message: str) -> AbstractContextManager[object]:
|
|
144
|
+
"""Show progress while a step runs.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
err: The stderr console. Progress is never data.
|
|
148
|
+
message: What is happening, in the present tense.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
A context manager, which does nothing at all when stderr is not a
|
|
152
|
+
terminal -- a spinner in a log file is just escape codes.
|
|
153
|
+
"""
|
|
154
|
+
return err.status(message) if err.is_terminal else _null()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def live(renderable: RenderableType, target: Console) -> Live:
|
|
158
|
+
"""Open a display that is redrawn in place.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
renderable: The first frame.
|
|
162
|
+
target: Where to draw.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The display. Manual refresh, because every caller here redraws in
|
|
166
|
+
response to something arriving rather than on a clock.
|
|
167
|
+
"""
|
|
168
|
+
return Live(renderable, console=target, auto_refresh=False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def usage(message: str, hint: str | None = None) -> int:
|
|
172
|
+
"""Report a problem with the command line itself.
|
|
173
|
+
|
|
174
|
+
Presented like any other expected failure -- one sentence and a suggestion
|
|
175
|
+
-- rather than through a cyclopts group validator, whose wording ("Invalid
|
|
176
|
+
values for group Program...") is written for whoever wrote the CLI rather
|
|
177
|
+
than for whoever is using it.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
message: What is wrong with the arguments.
|
|
181
|
+
hint: What to type instead.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The process exit code.
|
|
185
|
+
"""
|
|
186
|
+
return errors.render(console(err=True), [errors.Problem(message, hint)])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_TRANSPORT_NAMES: Final[Mapping[str, str]] = {
|
|
190
|
+
"ble": "Bluetooth",
|
|
191
|
+
"rfcomm": "Bluetooth Classic",
|
|
192
|
+
"usb": "USB",
|
|
193
|
+
"fake": "simulated",
|
|
194
|
+
}
|
|
195
|
+
"""What to call each way of reaching a hub.
|
|
196
|
+
|
|
197
|
+
The service's words are ``ble``, ``rfcomm`` and ``usb``, which are the right
|
|
198
|
+
names on the wire and the wrong ones on a screen: nobody calls it BLE or RFCOMM
|
|
199
|
+
except us. Classic is Bluetooth's own name for the older radio the EV3 and NXT
|
|
200
|
+
use, and it keeps the two apart where both are in use.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def transport_name(transport: str) -> str | None:
|
|
205
|
+
"""Say how a hub is reached, in a user's words.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
transport: What the service reported.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The wording, or ``None`` if the service said nothing -- which is what an
|
|
212
|
+
older service looks like, and is a reason to leave the line out rather
|
|
213
|
+
than to show an empty one. An unrecognised transport is passed through
|
|
214
|
+
as it came, since a name we do not have is still better than no answer.
|
|
215
|
+
"""
|
|
216
|
+
if not transport:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
return _TRANSPORT_NAMES.get(transport, transport)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def hub_warnings(status_names: Iterable[str]) -> list[str]:
|
|
223
|
+
"""Translate the hub's status flags into things worth telling the user.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
status_names: Flag names as the hub reported them.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The wording for each flag that says something actionable, in the order
|
|
230
|
+
given and without repeats -- two flags can mean the same thing to a
|
|
231
|
+
user, and saying it twice would look like a fault in the tool.
|
|
232
|
+
"""
|
|
233
|
+
said: list[str] = []
|
|
234
|
+
|
|
235
|
+
for name in status_names:
|
|
236
|
+
warning = HUB_WARNINGS.get(name)
|
|
237
|
+
if warning is not None and warning not in said:
|
|
238
|
+
said.append(warning)
|
|
239
|
+
|
|
240
|
+
return said
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def signal_bars(rssi: int | None) -> str:
|
|
244
|
+
"""Draw a signal strength as a bar chart, in Rich markup.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
rssi: The received signal strength in dBm, as Bluetooth reports it, or
|
|
248
|
+
``None`` when the transport does not measure one.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Four characters wide however strong the signal, so that rows stay
|
|
252
|
+
aligned. A number in dBm means nothing to most people, but "how many
|
|
253
|
+
bars" is something everyone already reads off a phone.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
if rssi is None:
|
|
257
|
+
return f"[dim]{SIGNAL_EMPTY * len(SIGNAL_BLOCKS)}[/]"
|
|
258
|
+
|
|
259
|
+
# At least one bar: the hub was heard, so it is out there somewhere.
|
|
260
|
+
level = max(1, sum(1 for floor in _SIGNAL_FLOORS if rssi >= floor))
|
|
261
|
+
style = "green" if level >= 3 else "yellow" if level == 2 else "red"
|
|
262
|
+
filled = SIGNAL_BLOCKS[:level]
|
|
263
|
+
empty = SIGNAL_EMPTY * (len(SIGNAL_BLOCKS) - level)
|
|
264
|
+
return f"[{style}]{filled}[/][dim]{empty}[/]"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def plain(out: Console, line: str) -> None:
|
|
268
|
+
"""Write one line to be read by another program, exactly as given.
|
|
269
|
+
|
|
270
|
+
Not through :meth:`rich.console.Console.print`, which is for text meant for
|
|
271
|
+
a person. Rich expands tabs as it renders and wraps at the console width --
|
|
272
|
+
80 columns when it cannot see a terminal -- so a tab-separated line printed
|
|
273
|
+
through it arrives with its tabs turned into spaces, and a long one arrives
|
|
274
|
+
folded in half. Both quietly break ``cut -f2`` and everything like it, and
|
|
275
|
+
neither shows up until somebody pipes the output somewhere.
|
|
276
|
+
|
|
277
|
+
Flushed, because some of what is written this way is written while a
|
|
278
|
+
command is still running -- hubs as a scan hears them, lines as the service
|
|
279
|
+
logs them -- and a pipe buffers in blocks. Without this, ``pb scan |
|
|
280
|
+
head -1`` waits for the scan to end rather than answering the moment there
|
|
281
|
+
is an answer, and ``pb service log -f`` piped anywhere shows nothing at all
|
|
282
|
+
until it is stopped. The cost is nothing at these volumes: a hub's own
|
|
283
|
+
output does not come this way.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
out: The stdout console. Only data is written this way.
|
|
287
|
+
line: Exactly what to write, without its newline.
|
|
288
|
+
"""
|
|
289
|
+
out.file.write(line + "\n")
|
|
290
|
+
out.file.flush()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def rows(out: Console, pairs: list[tuple[str, str]]) -> None:
|
|
294
|
+
"""Render key/value rows.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
out: Where to write. This is data, so it is stdout.
|
|
298
|
+
pairs: Name and value for each row, in the order to show them.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
if out.is_terminal:
|
|
302
|
+
table = Table(show_header=False, box=None)
|
|
303
|
+
|
|
304
|
+
for key, value in pairs:
|
|
305
|
+
table.add_row(key, value)
|
|
306
|
+
|
|
307
|
+
out.print(table)
|
|
308
|
+
else:
|
|
309
|
+
# Tab-separated when piped, so cut and awk can read it.
|
|
310
|
+
for key, value in pairs:
|
|
311
|
+
plain(out, f"{key}\t{value}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def signal_cell(device: pb_client.DeviceInfo, connected: Collection[str] = ()) -> str:
|
|
315
|
+
"""Say how well a hub can be reached, in Rich markup.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
device: The hub.
|
|
319
|
+
connected: The ids of hubs the service is holding open.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
A meter for a hub that was heard over Bluetooth, and a word for the
|
|
323
|
+
cases where a meter would be a lie. A connected hub is not advertising,
|
|
324
|
+
so there is no signal to draw; a hub on a cable has no signal at all,
|
|
325
|
+
and drawing an empty meter for one would read as a hub that can barely
|
|
326
|
+
be heard when it is in fact the best-connected hub on the list. A hub
|
|
327
|
+
listed over Bluetooth Classic was not heard at all: it is there because
|
|
328
|
+
it is paired, which says nothing about whether it is switched on.
|
|
329
|
+
"""
|
|
330
|
+
if device.id in connected:
|
|
331
|
+
return "[green]connected[/]"
|
|
332
|
+
|
|
333
|
+
if device.transport == "usb":
|
|
334
|
+
return "[green]USB[/]"
|
|
335
|
+
|
|
336
|
+
if device.transport == "rfcomm":
|
|
337
|
+
return "[dim]paired[/]"
|
|
338
|
+
|
|
339
|
+
return signal_bars(device.rssi)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def scan_table(
|
|
343
|
+
devices: Iterable[pb_client.DeviceInfo],
|
|
344
|
+
caption: str = "",
|
|
345
|
+
selected: int | None = None,
|
|
346
|
+
connected: Collection[str] = (),
|
|
347
|
+
) -> Table:
|
|
348
|
+
"""Build the table of hubs seen so far.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
devices: What has been seen, in the order it was first seen. Not sorted
|
|
352
|
+
by signal, tempting as that is: rows that swap places under the
|
|
353
|
+
reader's eyes while they are trying to read one are worse than a
|
|
354
|
+
list that is not in the ideal order.
|
|
355
|
+
caption: A line under the table saying what is happening.
|
|
356
|
+
selected: Which row the user is on, when this is a chooser rather than
|
|
357
|
+
a report. Marked with an arrow as well as with a style, so it is
|
|
358
|
+
still visible where colour is not.
|
|
359
|
+
connected: The ids of hubs that are connected. A connected hub does
|
|
360
|
+
not advertise, so there is no signal to report for one and the
|
|
361
|
+
column says so instead of drawing an empty meter.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The table, ready to print or hand to a live display.
|
|
365
|
+
"""
|
|
366
|
+
table = Table(caption=caption or None, caption_justify="left")
|
|
367
|
+
|
|
368
|
+
if selected is not None:
|
|
369
|
+
table.add_column("")
|
|
370
|
+
|
|
371
|
+
table.add_column("Name")
|
|
372
|
+
# Known before connecting, because a Pybricks hub puts its Plug and Play ID
|
|
373
|
+
# in the scan response. Two hubs called "Pybricks Hub" are told apart by
|
|
374
|
+
# this and by their signal, which is the whole reason to show either.
|
|
375
|
+
table.add_column("Type")
|
|
376
|
+
|
|
377
|
+
if SHOW_HUB_IDS:
|
|
378
|
+
table.add_column("Address", style="dim")
|
|
379
|
+
|
|
380
|
+
table.add_column("Signal", justify="right")
|
|
381
|
+
|
|
382
|
+
for index, device in enumerate(devices):
|
|
383
|
+
row = [
|
|
384
|
+
device.name,
|
|
385
|
+
device.hub_type or "",
|
|
386
|
+
device.id,
|
|
387
|
+
signal_cell(device, connected),
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
if not SHOW_HUB_IDS:
|
|
391
|
+
del row[2]
|
|
392
|
+
|
|
393
|
+
if selected is not None:
|
|
394
|
+
row.insert(0, SELECTED_MARK if index == selected else " ")
|
|
395
|
+
|
|
396
|
+
table.add_row(*row, style="bold" if index == selected else None)
|
|
397
|
+
|
|
398
|
+
return table
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def connected_table(hubs: Iterable[pb_client.HubInfo], acting: str | None) -> Table:
|
|
402
|
+
"""Build the table of hubs the service is holding open.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
hubs: The connected hubs.
|
|
406
|
+
acting: The id of the one a command with no hub named would act
|
|
407
|
+
on, marked with a star.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
The table.
|
|
411
|
+
|
|
412
|
+
There is no state column. This lists what is connected, so it could only
|
|
413
|
+
ever say "connected"; it comes back if the service ever tracks hubs it
|
|
414
|
+
knows about but is not holding.
|
|
415
|
+
"""
|
|
416
|
+
table = Table()
|
|
417
|
+
table.add_column("")
|
|
418
|
+
table.add_column("Name")
|
|
419
|
+
table.add_column("Firmware")
|
|
420
|
+
table.add_column("Used by", justify="right")
|
|
421
|
+
|
|
422
|
+
for info in hubs:
|
|
423
|
+
table.add_row(
|
|
424
|
+
"*" if info.id == acting else "",
|
|
425
|
+
info.name,
|
|
426
|
+
info.firmware,
|
|
427
|
+
str(info.subscribers),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return table
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def chooser_table(
|
|
434
|
+
devices: list[pb_client.DeviceInfo],
|
|
435
|
+
selected: int,
|
|
436
|
+
connected: Collection[str] = (),
|
|
437
|
+
) -> RenderableType:
|
|
438
|
+
"""Draw the chooser's list.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
devices: The hubs heard so far.
|
|
442
|
+
selected: Which one the user is on.
|
|
443
|
+
connected: The ids of hubs that are connected rather than heard.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
What to show, which is a line of text until a hub has been heard -- an
|
|
447
|
+
empty table with headings would look like a list of no hubs rather than
|
|
448
|
+
like still looking for them.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
if not devices:
|
|
452
|
+
return Text("Looking for hubs...", style="dim")
|
|
453
|
+
|
|
454
|
+
return scan_table(devices, selected=selected, connected=connected)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybricks-cli
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: The Pybricks command line interface
|
|
5
|
+
Keywords: pybricks,lego,micropython,bluetooth,robotics
|
|
6
|
+
Author: The Pybricks Authors
|
|
7
|
+
Author-email: The Pybricks Authors <dev@pybricks.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: pybricks-core>=1.0.0b1
|
|
19
|
+
Requires-Dist: pybricks-client>=1.0.0b1
|
|
20
|
+
Requires-Dist: pybricks-service>=1.0.0b1
|
|
21
|
+
Requires-Dist: anyio>=4.14
|
|
22
|
+
Requires-Dist: cyclopts>=4.22
|
|
23
|
+
Requires-Dist: rich>=13.7
|
|
24
|
+
Requires-Dist: typing-extensions>=4.15
|
|
25
|
+
Requires-Dist: watchfiles>=1.2
|
|
26
|
+
Maintainer: David Lechner
|
|
27
|
+
Maintainer-email: David Lechner <david@pybricks.com>
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Project-URL: homepage, https://pybricks.com
|
|
30
|
+
Project-URL: repository, https://github.com/pybricks/pybricks-tools
|
|
31
|
+
Project-URL: documentation, https://pybricks.com/pybricks-tools/cli/
|
|
32
|
+
Project-URL: Changelog, https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-cli/CHANGELOG.md
|
|
33
|
+
Project-URL: Support, https://github.com/pybricks/support/discussions
|
|
34
|
+
Project-URL: Issues, https://github.com/pybricks/support/issues
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# pybricks-cli
|
|
38
|
+
|
|
39
|
+
The `pb` command: run Python programs on a [Pybricks](https://pybricks.com) hub
|
|
40
|
+
from your computer.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv tool install pybricks-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then switch a hub on:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pb connect # find a hub and connect to it
|
|
50
|
+
pb run demo.py # compile the script, send it, and run it
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**The hub stays connected after the command finishes**, so the second `pb run`
|
|
54
|
+
starts instantly, and so does the hundredth.
|
|
55
|
+
|
|
56
|
+
`pb repl` talks to the hub's interpreter a line at a time, and `pb run -c` runs
|
|
57
|
+
a line or two of Python without a file. `pb --help` lists the rest.
|
|
58
|
+
|
|
59
|
+
> **Status:** beta. Commands and APIs may still change before 1.0.0.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
pb_cli/__init__.py,sha256=OOzz50wu4R3xbPJmGzcd1cgVOMMFXCT-t1xSqsWWc3Y,169
|
|
2
|
+
pb_cli/__main__.py,sha256=daU1PjUYs4oc_iRv6SrANPvszOQ4JT2XUahD4xAloR4,159
|
|
3
|
+
pb_cli/app.py,sha256=_FGyMwKnrtcfDd_ZPm_14xlkV4B1E0zFUb8AGPdn2Lw,4331
|
|
4
|
+
pb_cli/banner.py,sha256=_TvHn0SnYLqaoEimZ0lWfy3r0QUUAvwB4qbPnWHbLf8,10910
|
|
5
|
+
pb_cli/cli.py,sha256=44dmk8U_qUrsT7Q5zOdybOG5g254EohhxsHCxx8qAzo,4766
|
|
6
|
+
pb_cli/commands/__init__.py,sha256=Q776Ajsv54PlCL4hViY5amUAwSTtsKEHvWYnMgBFBtA,667
|
|
7
|
+
pb_cli/commands/hub.py,sha256=vahVx51zNcxa9FuDSy2NRsncr1D32CpLcFK7XlTXLts,22493
|
|
8
|
+
pb_cli/commands/program.py,sha256=5cA74hDjg91LSr0quLaHKy_7Jk0VMVKVdicHOyCQvnE,14207
|
|
9
|
+
pb_cli/commands/repl.py,sha256=1zBumG_Qf5UgNKq76SUNmwmkAM4vvV0fX5gM52KJ_MQ,5111
|
|
10
|
+
pb_cli/commands/service.py,sha256=4NL17Rk6XyK6rn5xuX85EisOlFZjF0SOl-RidMu3ilU,12378
|
|
11
|
+
pb_cli/config.py,sha256=F154YJ7wdDEwk7WF4KlDqKNsVqtV7hGQjMYfzdjbhLY,7565
|
|
12
|
+
pb_cli/console/__init__.py,sha256=0Gc7a1TEmustjYYN_XFmp8AaoAZOvxRFD96KN4AGcIc,4189
|
|
13
|
+
pb_cli/console/posix.py,sha256=OFC4XeS-MtCXCjObrArK10fAGTN1_gBOdpkeEUtRbAw,3264
|
|
14
|
+
pb_cli/console/windows.py,sha256=R_cwNr0rbO-wXmxfWdglzlXyNmQck_Uv5uc7Ez5vxzI,20578
|
|
15
|
+
pb_cli/errors.py,sha256=N7gHXWpTiKgcqsWcR2bJUqj2yO50GuI5L1bF-6aRc9c,24741
|
|
16
|
+
pb_cli/escape.py,sha256=mjy3KR2Ro3Akp2GkqaXF_Snu4hhRbq_4Q_RVuXiROCs,10130
|
|
17
|
+
pb_cli/keys.py,sha256=cHhUmO1H96VSHGRKlarF_hwkiVeZhjP1FBuOsUH-Ut8,12533
|
|
18
|
+
pb_cli/picker.py,sha256=1N6TTUxEWOYMaYz7v2xvq6g8CydTwLl168KdE1l-No4,5026
|
|
19
|
+
pb_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
pb_cli/session.py,sha256=3-QOOwXa8NIh7h32kR8jSzQsAjLqJpeNOewEdzq66zc,29910
|
|
21
|
+
pb_cli/ui.py,sha256=sjGcaHRVH9hJUjMo43F5XrsKLSe8xeV0JAZZD2WaT5E,16154
|
|
22
|
+
pybricks_cli-1.0.0b1.dist-info/licenses/LICENSE,sha256=JndG-VHDIn3qDRbnL3i1F01gYV64xJKk3bjgY8hSe2o,1077
|
|
23
|
+
pybricks_cli-1.0.0b1.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
|
|
24
|
+
pybricks_cli-1.0.0b1.dist-info/entry_points.txt,sha256=mwERougZL-mI4AcHM3DhWGhA3hBuWjwLEqHcoM5nnK8,40
|
|
25
|
+
pybricks_cli-1.0.0b1.dist-info/METADATA,sha256=vvbhNYgynuNAvj-JXT-VnltRwSWhQIfTiD-9Si3SvzE,2117
|
|
26
|
+
pybricks_cli-1.0.0b1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Pybricks Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|