Skip to content

Commit 585168c

Browse files
authored
cli: add --stay-connected argument run command
Add a `--stay-connected` argument that causes the `pybricksdev run` command to stay connected after the user program ends (implies `--wait`). After this, an interactive menu is show to allow compiling and running or downloading the program again. Also cancels the menu if the program is started by pressing the button on the hub so that stdout can print.
1 parent bb9b718 commit 585168c

File tree

6 files changed

+231
-4
lines changed

6 files changed

+231
-4
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Added the `--stay-connected` arg to the `pybricksdev run`
12+
command, allowing re-compiling and running the input file. Also echoes
13+
the hub's output to the console when manually running a program.
14+
([pybricksdev#122])
15+
16+
[pybricksdev#122]: https://github.com/pybricks/pybricksdev/pull/122
17+
18+
919
## [2.0.1] - 2025-08-11
1020

1121
### Fixed

poetry.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pybricksdev/cli/__init__.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515
from typing import ContextManager, TextIO
1616

1717
import argcomplete
18+
import questionary
1819
from argcomplete.completers import FilesCompleter
1920

2021
from pybricksdev import __name__ as MODULE_NAME
2122
from pybricksdev import __version__ as MODULE_VERSION
23+
from pybricksdev.connections.pybricks import (
24+
HubDisconnectError,
25+
HubPowerButtonPressedError,
26+
)
2227

2328
PROG_NAME = (
2429
f"{path.basename(sys.executable)} -m {MODULE_NAME}"
@@ -160,6 +165,13 @@ def add_parser(self, subparsers: argparse._SubParsersAction):
160165
default=True,
161166
)
162167

168+
parser.add_argument(
169+
"--stay-connected",
170+
help="Add a menu option to resend the code with bluetooth instead of disconnecting from the robot after the program ends.",
171+
action=argparse.BooleanOptionalAction,
172+
default=False,
173+
)
174+
163175
async def run(self, args: argparse.Namespace):
164176

165177
# Pick the right connection
@@ -215,14 +227,99 @@ def is_pybricks_usb(dev):
215227
try:
216228
with _get_script_path(args.file) as script_path:
217229
if args.start:
218-
await hub.run(script_path, args.wait)
230+
await hub.run(script_path, args.wait or args.stay_connected)
219231
else:
232+
if args.stay_connected:
233+
# if the user later starts the program by pressing the button on the hub,
234+
# we still want the hub stdout to print to Python's stdout
235+
hub.print_output = True
236+
hub._enable_line_handler = True
220237
await hub.download(script_path)
238+
239+
if not args.stay_connected:
240+
return
241+
242+
async def reconnect_hub():
243+
if not await questionary.confirm(
244+
"\nThe hub has been disconnected. Would you like to re-connect?"
245+
).ask_async():
246+
exit()
247+
248+
if args.conntype == "ble":
249+
print(
250+
f"Searching for {args.name or 'any hub with Pybricks service'}..."
251+
)
252+
device_or_address = await find_ble(args.name)
253+
hub = PybricksHubBLE(device_or_address)
254+
elif args.conntype == "usb":
255+
device_or_address = find_usb(custom_match=is_pybricks_usb)
256+
hub = PybricksHubUSB(device_or_address)
257+
258+
await hub.connect()
259+
# re-enable echoing of the hub's stdout
260+
hub._enable_line_handler = True
261+
hub.print_output = True
262+
return hub
263+
264+
response_options = [
265+
"Recompile and Run",
266+
"Recompile and Download",
267+
"Exit",
268+
]
269+
while True:
270+
try:
271+
if args.file is sys.stdin:
272+
await hub.race_disconnect(
273+
hub.race_power_button_press(
274+
questionary.press_any_key_to_continue(
275+
"The hub will stay connected and echo its output to the terminal. Press any key to exit."
276+
).ask_async()
277+
)
278+
)
279+
return
280+
response = await hub.race_disconnect(
281+
hub.race_power_button_press(
282+
questionary.select(
283+
"Would you like to re-compile your code?",
284+
response_options,
285+
default=(
286+
response_options[0]
287+
if args.start
288+
else response_options[1]
289+
),
290+
).ask_async()
291+
)
292+
)
293+
with _get_script_path(args.file) as script_path:
294+
if response == response_options[0]:
295+
await hub.run(script_path, wait=True)
296+
elif response == response_options[1]:
297+
await hub.download(script_path)
298+
else:
299+
return
300+
301+
except HubPowerButtonPressedError:
302+
# This means the user pressed the button on the hub to re-start the
303+
# current program, so the menu was canceled and we are now printing
304+
# the hub stdout until the user program ends on the hub.
305+
try:
306+
await hub._wait_for_power_button_release()
307+
await hub._wait_for_user_program_stop()
308+
309+
except HubDisconnectError:
310+
hub = await reconnect_hub()
311+
312+
except HubDisconnectError:
313+
# let terminal cool off before making a new prompt
314+
await asyncio.sleep(0.3)
315+
hub = await reconnect_hub()
316+
221317
finally:
222318
await hub.disconnect()
223319

224320

225321
class Flash(Tool):
322+
226323
def add_parser(self, subparsers: argparse._SubParsersAction):
227324
parser = subparsers.add_parser(
228325
"flash", help="flash firmware on a LEGO Powered Up device"

pybricksdev/connections/pybricks.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@
6565
T = TypeVar("T")
6666

6767

68+
class HubDisconnectError(RuntimeError):
69+
"""Raise when a hub disconnect occurs."""
70+
71+
72+
class HubPowerButtonPressedError(RuntimeError):
73+
"""Raise when a task was canceled because the hub's power button was pressed."""
74+
75+
6876
class PybricksHub:
6977
EOL = b"\r\n" # MicroPython EOL
7078

@@ -363,7 +371,7 @@ def handle_disconnect(state: ConnectionState):
363371
t.cancel()
364372

365373
if awaitable_task not in done:
366-
raise RuntimeError("disconnected during operation")
374+
raise HubDisconnectError("disconnected during operation")
367375

368376
return awaitable_task.result()
369377

@@ -683,6 +691,92 @@ async def send_block(data: bytes) -> None:
683691
if wait:
684692
await self._wait_for_user_program_stop()
685693

694+
async def race_power_button_press(self, awaitable: Awaitable[T]) -> T:
695+
"""
696+
Races an awaitable against the user pressing the power button of the hub.
697+
If the power button is pressed or the hub becomes disconnected before the awaitable is complete, a
698+
:class:`HubPowerButtonPressedError` or :class:`HubDisconnectError` is raised and the awaitable is canceled.
699+
Otherwise, the result of the awaitable is returned. If the awaitable
700+
raises an exception, that exception will be raised.
701+
The intended purpose of this function is to detect when
702+
the user manually starts a program on the hub. It is used instead of the program running flag
703+
because the hub can send info through stdout before we can detect a change in the program running flag.
704+
705+
Args:
706+
awaitable: Any awaitable such as a coroutine.
707+
708+
Returns:
709+
The result of the awaitable.
710+
711+
Raises:
712+
HubPowerButtonPressedError
713+
HubDisconnectError
714+
"""
715+
awaitable_task = asyncio.ensure_future(awaitable)
716+
717+
power_button_press_event = asyncio.Event()
718+
power_button_press_task = asyncio.ensure_future(power_button_press_event.wait())
719+
720+
def handle_power_button_press(status: StatusFlag):
721+
if status.value & StatusFlag.POWER_BUTTON_PRESSED:
722+
power_button_press_event.set()
723+
724+
with self.status_observable.subscribe(handle_power_button_press):
725+
try:
726+
done, pending = await asyncio.wait(
727+
{awaitable_task, power_button_press_task},
728+
return_when=asyncio.FIRST_COMPLETED,
729+
)
730+
except BaseException:
731+
awaitable_task.cancel()
732+
power_button_press_task.cancel()
733+
raise
734+
735+
for t in pending:
736+
t.cancel()
737+
738+
if power_button_press_task in done:
739+
raise HubPowerButtonPressedError(
740+
"the hub's power button was pressed during operation"
741+
)
742+
return awaitable_task.result()
743+
744+
async def _wait_for_power_button_release(self):
745+
power_button_pressed: asyncio.Queue[bool] = asyncio.Queue()
746+
747+
with self.status_observable.pipe(
748+
op.map(lambda s: bool(s & StatusFlag.POWER_BUTTON_PRESSED)),
749+
op.distinct_until_changed(),
750+
).subscribe(lambda s: power_button_pressed.put_nowait(s)):
751+
# The first item in the queue is the current status. The status
752+
# could change before or after the last checksum is received,
753+
# so this could be either true or false.
754+
is_pressed = await self.race_disconnect(power_button_pressed.get())
755+
756+
if not is_pressed:
757+
# If the button isn't already pressed,
758+
# wait a short time for it to become pressed
759+
try:
760+
await asyncio.wait_for(
761+
self.race_disconnect(power_button_pressed.get()),
762+
1,
763+
)
764+
except asyncio.TimeoutError:
765+
# If the button never shows as pressed,
766+
# assume that we just missed the status flag
767+
logger.debug(
768+
"timed out waiting for power button press, assuming is was a short press"
769+
)
770+
return
771+
772+
# At this point, we know the button is pressed, so the
773+
# next item in the queue must indicate the button has
774+
# been released.
775+
is_pressed = await self.race_disconnect(power_button_pressed.get())
776+
777+
# maybe catch mistake if the code is changed
778+
assert not is_pressed
779+
686780
async def _wait_for_user_program_stop(self):
687781
user_program_running: asyncio.Queue[bool] = asyncio.Queue()
688782

@@ -700,7 +794,8 @@ async def _wait_for_user_program_stop(self):
700794
# for it to start
701795
try:
702796
await asyncio.wait_for(
703-
self.race_disconnect(user_program_running.get()), 1
797+
self.race_disconnect(user_program_running.get()),
798+
1,
704799
)
705800
except asyncio.TimeoutError:
706801
# if it doesn't start, assume it was a very short lived

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ typing-extensions = ">=4.3.0"
4848
reactivex = {version = ">=4.0.4", python = "<4"}
4949
hidapi = ">=0.14.0"
5050
pybricks = {version = ">=3", allow-prereleases = true, python = "<4"}
51+
questionary = {version = ">=2.1.1", python = "<4"}
5152

5253
[tool.poetry.group.lint.dependencies]
5354
black = ">=23,<25"

tests/test_cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ async def test_download_ble(self):
8686
name="MyHub",
8787
start=False,
8888
wait=False,
89+
stay_connected=False,
8990
)
9091

9192
mock_hub_class = stack.enter_context(
@@ -135,6 +136,7 @@ async def test_download_usb(self):
135136
name=None,
136137
start=False,
137138
wait=False,
139+
stay_connected=False,
138140
)
139141

140142
mock_hub_class = stack.enter_context(
@@ -175,6 +177,7 @@ async def test_download_stdin(self):
175177
name="MyHub",
176178
start=False,
177179
wait=False,
180+
stay_connected=False,
178181
)
179182

180183
# Set up mocks using ExitStack
@@ -227,6 +230,7 @@ async def test_download_connection_error(self):
227230
name="MyHub",
228231
start=False,
229232
wait=False,
233+
stay_connected=False,
230234
)
231235

232236
stack.enter_context(
@@ -273,6 +277,7 @@ async def test_run_ble(self):
273277
name="MyHub",
274278
start=True,
275279
wait=True,
280+
stay_connected=False,
276281
)
277282

278283
mock_hub_class = stack.enter_context(
@@ -321,6 +326,7 @@ async def test_run_usb(self):
321326
name=None,
322327
start=True,
323328
wait=True,
329+
stay_connected=False,
324330
)
325331

326332
mock_hub_class = stack.enter_context(
@@ -360,6 +366,7 @@ async def test_run_stdin(self):
360366
name="MyHub",
361367
start=True,
362368
wait=True,
369+
stay_connected=False,
363370
)
364371

365372
# Set up mocks using ExitStack
@@ -414,6 +421,7 @@ async def test_run_connection_error(self):
414421
name="MyHub",
415422
start=False,
416423
wait=True,
424+
stay_connected=False,
417425
)
418426

419427
stack.enter_context(

0 commit comments

Comments
 (0)