From 07cacfcc90f1034ac85292f1920be0ef6746363a Mon Sep 17 00:00:00 2001 From: shaggy Date: Wed, 27 Aug 2025 17:31:13 -0500 Subject: [PATCH 01/27] change code sending script to stay connected to robot indefinitely --- pybricksdev/cli/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index a2383c0..89fbe38 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -213,11 +213,15 @@ def is_pybricks_usb(dev): # Connect to the address and run the script await hub.connect() try: - with _get_script_path(args.file) as script_path: - if args.start: - await hub.run(script_path, args.wait) - else: - await hub.download(script_path) + while True: + with _get_script_path(args.file) as script_path: + if args.start: + await hub.run(script_path, args.wait) + else: + await hub.download(script_path) + choice = input("Press enter to resend, Q to quit") + if choice.upper() == "Q": + break finally: await hub.disconnect() From 9dfd844f4270b41eb00fb3f450b64d571c889f15 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sat, 30 Aug 2025 09:51:34 -0500 Subject: [PATCH 02/27] add a menu to select options --- demo/longdemo.py | 12 ++++++++++++ pybricksdev/cli/__init__.py | 6 ++++-- pyproject.toml | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/demo/longdemo.py b/demo/longdemo.py index 33886c0..1cc5cb0 100644 --- a/demo/longdemo.py +++ b/demo/longdemo.py @@ -402,3 +402,15 @@ a = a + 1 a = a + 1 print(a) +a = a + 1 +a = a + 1 +a = a + 1 +a = a + 1 +a = a + 1 +print(a) +a = a + 1 +a = a + 1 +a = a + 1 +a = a + 1 +a = a + 1 +print(a) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 89fbe38..ea4a3f9 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -16,6 +16,7 @@ import argcomplete from argcomplete.completers import FilesCompleter +import simple_term_menu from pybricksdev import __name__ as MODULE_NAME from pybricksdev import __version__ as MODULE_VERSION @@ -219,8 +220,9 @@ def is_pybricks_usb(dev): await hub.run(script_path, args.wait) else: await hub.download(script_path) - choice = input("Press enter to resend, Q to quit") - if choice.upper() == "Q": + menu = simple_term_menu.TerminalMenu(["Resend Code", "Exit"]) + entry = menu.show() + if entry: break finally: await hub.disconnect() diff --git a/pyproject.toml b/pyproject.toml index ae1510c..1b6cb68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ typing-extensions = ">=4.3.0" reactivex = {version = ">=4.0.4", python = "<4"} hidapi = ">=0.14.0" pybricks = {version = ">=3", allow-prereleases = true, python = "<4"} +simple-term-menu = ">=1.6.6" [tool.poetry.group.lint.dependencies] black = ">=23,<25" From 018af5a403e92ff9324afd8c4ad7dae291eda588 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sat, 30 Aug 2025 20:22:00 -0500 Subject: [PATCH 03/27] move code resending to optional argument --- pybricksdev/cli/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index ea4a3f9..57fa2f0 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -161,6 +161,13 @@ def add_parser(self, subparsers: argparse._SubParsersAction): default=True, ) + parser.add_argument( + "--resend", + help="Add a menu option to resend the code instead of disconnecting from the robot.", + action=argparse.BooleanOptionalAction, + default=False, + ) + async def run(self, args: argparse.Namespace): # Pick the right connection @@ -220,10 +227,16 @@ def is_pybricks_usb(dev): await hub.run(script_path, args.wait) else: await hub.download(script_path) + + if not args.resend: + break + menu = simple_term_menu.TerminalMenu(["Resend Code", "Exit"]) entry = menu.show() + if entry: break + finally: await hub.disconnect() From e1683b165958a04205de1c1275d608cb6378b7a4 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sat, 30 Aug 2025 20:30:28 -0500 Subject: [PATCH 04/27] disable code resending when using usb --- pybricksdev/cli/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 57fa2f0..2176e94 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -163,7 +163,7 @@ def add_parser(self, subparsers: argparse._SubParsersAction): parser.add_argument( "--resend", - help="Add a menu option to resend the code instead of disconnecting from the robot.", + help="Add a menu option to resend the code with bluetooth instead of disconnecting from the robot after the program ends.", action=argparse.BooleanOptionalAction, default=False, ) @@ -228,7 +228,7 @@ def is_pybricks_usb(dev): else: await hub.download(script_path) - if not args.resend: + if args.conntype == "usb" or not args.wait or not args.resend: break menu = simple_term_menu.TerminalMenu(["Resend Code", "Exit"]) From aa342f4209d542593cbf143aa5bcf1d9424912a7 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sat, 30 Aug 2025 20:31:45 -0500 Subject: [PATCH 05/27] bring longdemo.py back to original form --- demo/longdemo.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/demo/longdemo.py b/demo/longdemo.py index 1cc5cb0..33886c0 100644 --- a/demo/longdemo.py +++ b/demo/longdemo.py @@ -402,15 +402,3 @@ a = a + 1 a = a + 1 print(a) -a = a + 1 -a = a + 1 -a = a + 1 -a = a + 1 -a = a + 1 -print(a) -a = a + 1 -a = a + 1 -a = a + 1 -a = a + 1 -a = a + 1 -print(a) From 1180a0d49afb2b2b2b88020a2f3a165f4581b645 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sat, 30 Aug 2025 22:29:44 -0500 Subject: [PATCH 06/27] fix poetry lock file --- poetry.lock | 15 ++++++++++++++- pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index c1c03c0..47ffd55 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1150,6 +1150,19 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +[[package]] +name = "simple-term-menu" +version = "1.6.6" +description = "A Python package which creates simple interactive menus on the command line." +optional = false +python-versions = "~=3.5" +groups = ["main"] +markers = "python_version < \"4\"" +files = [ + {file = "simple_term_menu-1.6.6-py3-none-any.whl", hash = "sha256:c2a869efa7a9f7e4a9c25858b42ca6974034951c137d5e281f5339b06ed8c9c2"}, + {file = "simple_term_menu-1.6.6.tar.gz", hash = "sha256:9813d36f5749d62d200a5599b1ec88469c71378312adc084c00c00bfbb383893"}, +] + [[package]] name = "snowballstemmer" version = "3.0.1" @@ -1716,4 +1729,4 @@ all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt- [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "3d2a87a44fe61f7cbe48f13302f75cdcfa991b2ffc546e12818b9999050c5959" +content-hash = "8bc9508e78ff21ed74d559aa55ef719c47bccaa63b53c0c56173b43c206bcbf0" diff --git a/pyproject.toml b/pyproject.toml index 1b6cb68..45f189d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ typing-extensions = ">=4.3.0" reactivex = {version = ">=4.0.4", python = "<4"} hidapi = ">=0.14.0" pybricks = {version = ">=3", allow-prereleases = true, python = "<4"} -simple-term-menu = ">=1.6.6" +simple-term-menu = {version = ">=1.6.6", python = "<4"} [tool.poetry.group.lint.dependencies] black = ">=23,<25" From e83e1e94728d56f1aae5651461a3001a27b5f469 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sun, 31 Aug 2025 11:58:40 -0500 Subject: [PATCH 07/27] implement all suggestions --- poetry.lock | 31 +++++++++++++++++-------------- pybricksdev/cli/__init__.py | 18 +++++++++++------- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/poetry.lock b/poetry.lock index 47ffd55..017c5bc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1079,6 +1079,22 @@ files = [ {file = "pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e"}, ] +[[package]] +name = "questionary" +version = "2.1.1" +description = "Python library to build pretty command line user prompts ⭐️" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version < \"4\"" +files = [ + {file = "questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59"}, + {file = "questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d"}, +] + +[package.dependencies] +prompt_toolkit = ">=2.0,<4.0" + [[package]] name = "reactivex" version = "4.0.4" @@ -1150,19 +1166,6 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] -[[package]] -name = "simple-term-menu" -version = "1.6.6" -description = "A Python package which creates simple interactive menus on the command line." -optional = false -python-versions = "~=3.5" -groups = ["main"] -markers = "python_version < \"4\"" -files = [ - {file = "simple_term_menu-1.6.6-py3-none-any.whl", hash = "sha256:c2a869efa7a9f7e4a9c25858b42ca6974034951c137d5e281f5339b06ed8c9c2"}, - {file = "simple_term_menu-1.6.6.tar.gz", hash = "sha256:9813d36f5749d62d200a5599b1ec88469c71378312adc084c00c00bfbb383893"}, -] - [[package]] name = "snowballstemmer" version = "3.0.1" @@ -1729,4 +1732,4 @@ all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt- [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "8bc9508e78ff21ed74d559aa55ef719c47bccaa63b53c0c56173b43c206bcbf0" +content-hash = "71dd6b61b42043573f4f6f7d1b89ae384c60f320c10d45b9095125d1dec015a1" diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 2176e94..7429d66 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -13,10 +13,10 @@ from os import PathLike, path from tempfile import NamedTemporaryFile from typing import ContextManager, TextIO +import questionary import argcomplete from argcomplete.completers import FilesCompleter -import simple_term_menu from pybricksdev import __name__ as MODULE_NAME from pybricksdev import __version__ as MODULE_VERSION @@ -162,7 +162,7 @@ def add_parser(self, subparsers: argparse._SubParsersAction): ) parser.add_argument( - "--resend", + "--stay-connected", help="Add a menu option to resend the code with bluetooth instead of disconnecting from the robot after the program ends.", action=argparse.BooleanOptionalAction, default=False, @@ -228,19 +228,23 @@ def is_pybricks_usb(dev): else: await hub.download(script_path) - if args.conntype == "usb" or not args.wait or not args.resend: + if not args.wait or not args.stay_connected: break - menu = simple_term_menu.TerminalMenu(["Resend Code", "Exit"]) - entry = menu.show() + resend = await questionary.select( + "Would you like to resend your code?", + choices=["Resend", "Exit"] + ).ask_async() - if entry: + if resend == "Exit": break + except RuntimeError: + print("The hub is no longer connected.") + finally: await hub.disconnect() - class Flash(Tool): def add_parser(self, subparsers: argparse._SubParsersAction): parser = subparsers.add_parser( diff --git a/pyproject.toml b/pyproject.toml index 45f189d..37aa9c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ typing-extensions = ">=4.3.0" reactivex = {version = ">=4.0.4", python = "<4"} hidapi = ">=0.14.0" pybricks = {version = ">=3", allow-prereleases = true, python = "<4"} -simple-term-menu = {version = ">=1.6.6", python = "<4"} +questionary = {version = ">=2.1.1", python = "<4"} [tool.poetry.group.lint.dependencies] black = ">=23,<25" From 004b3f5b899a5aa6501f731df93151630016bba7 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sun, 31 Aug 2025 12:12:47 -0500 Subject: [PATCH 08/27] move import statement to more suitable location --- pybricksdev/cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 7429d66..10c62e7 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -4,6 +4,7 @@ """Command line wrapper around pybricksdev library.""" import argparse +import questionary import asyncio import contextlib import logging @@ -13,7 +14,6 @@ from os import PathLike, path from tempfile import NamedTemporaryFile from typing import ContextManager, TextIO -import questionary import argcomplete from argcomplete.completers import FilesCompleter From 4e878ee263fbdd41e91f34f8e4b2d2bb253a822e Mon Sep 17 00:00:00 2001 From: shaggy Date: Sun, 31 Aug 2025 13:26:42 -0500 Subject: [PATCH 09/27] move import statement to be in alphabetical order --- pybricksdev/cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 10c62e7..6e1aafd 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -4,11 +4,11 @@ """Command line wrapper around pybricksdev library.""" import argparse -import questionary import asyncio import contextlib import logging import os +import questionary import sys from abc import ABC, abstractmethod from os import PathLike, path From 41e7a77d75909d1bbafa8c15978da9bde9496206 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sun, 31 Aug 2025 13:59:54 -0500 Subject: [PATCH 10/27] change unit tests to use the stay_connected arg --- tests/test_cli.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 28f2330..b222405 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -86,6 +86,7 @@ async def test_download_ble(self): name="MyHub", start=False, wait=False, + stay_connected=False, ) mock_hub_class = stack.enter_context( @@ -135,6 +136,7 @@ async def test_download_usb(self): name=None, start=False, wait=False, + stay_connected=False, ) mock_hub_class = stack.enter_context( @@ -175,6 +177,7 @@ async def test_download_stdin(self): name="MyHub", start=False, wait=False, + stay_connected=False, ) # Set up mocks using ExitStack @@ -227,6 +230,7 @@ async def test_download_connection_error(self): name="MyHub", start=False, wait=False, + stay_connected=False, ) stack.enter_context( @@ -273,6 +277,7 @@ async def test_run_ble(self): name="MyHub", start=True, wait=True, + stay_connected=False, ) mock_hub_class = stack.enter_context( @@ -321,6 +326,7 @@ async def test_run_usb(self): name=None, start=True, wait=True, + stay_connected=False, ) mock_hub_class = stack.enter_context( @@ -360,6 +366,7 @@ async def test_run_stdin(self): name="MyHub", start=True, wait=True, + stay_connected=False, ) # Set up mocks using ExitStack @@ -414,6 +421,7 @@ async def test_run_connection_error(self): name="MyHub", start=False, wait=True, + stay_connected=False, ) stack.enter_context( From dc1ecac2182f73f1523a2b6b412c6ef36ca4be40 Mon Sep 17 00:00:00 2001 From: shaggy Date: Sun, 31 Aug 2025 14:10:21 -0500 Subject: [PATCH 11/27] fix formatting issues --- pybricksdev/cli/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 6e1aafd..7466bff 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -8,7 +8,6 @@ import contextlib import logging import os -import questionary import sys from abc import ABC, abstractmethod from os import PathLike, path @@ -16,6 +15,7 @@ from typing import ContextManager, TextIO import argcomplete +import questionary from argcomplete.completers import FilesCompleter from pybricksdev import __name__ as MODULE_NAME @@ -245,7 +245,9 @@ def is_pybricks_usb(dev): finally: await hub.disconnect() + class Flash(Tool): + def add_parser(self, subparsers: argparse._SubParsersAction): parser = subparsers.add_parser( "flash", help="flash firmware on a LEGO Powered Up device" From c4e0745e9035fdf2efa32790473546458ab72c4d Mon Sep 17 00:00:00 2001 From: shaggy Date: Sun, 31 Aug 2025 14:16:44 -0500 Subject: [PATCH 12/27] fix additional formatting problem --- pybricksdev/cli/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 6e1aafd..ccd6878 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -8,7 +8,6 @@ import contextlib import logging import os -import questionary import sys from abc import ABC, abstractmethod from os import PathLike, path @@ -16,6 +15,7 @@ from typing import ContextManager, TextIO import argcomplete +import questionary from argcomplete.completers import FilesCompleter from pybricksdev import __name__ as MODULE_NAME @@ -232,8 +232,7 @@ def is_pybricks_usb(dev): break resend = await questionary.select( - "Would you like to resend your code?", - choices=["Resend", "Exit"] + "Would you like to resend your code?", choices=["Resend", "Exit"] ).ask_async() if resend == "Exit": @@ -245,7 +244,9 @@ def is_pybricks_usb(dev): finally: await hub.disconnect() + class Flash(Tool): + def add_parser(self, subparsers: argparse._SubParsersAction): parser = subparsers.add_parser( "flash", help="flash firmware on a LEGO Powered Up device" From 3a595e4742998ed93ed3d226dcce1a155c4b6fd2 Mon Sep 17 00:00:00 2001 From: shaggy Date: Mon, 8 Sep 2025 09:03:59 -0500 Subject: [PATCH 13/27] fix the stdout echoing issue --- pybricksdev/cli/__init__.py | 88 ++++++++++++++++++++++------- pybricksdev/connections/pybricks.py | 55 +++++++++++++++++- 2 files changed, 122 insertions(+), 21 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index ccd6878..fceb8ed 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -20,6 +20,8 @@ from pybricksdev import __name__ as MODULE_NAME from pybricksdev import __version__ as MODULE_VERSION +from pybricksdev.ble.pybricks import StatusFlag +from pybricksdev.connections import ConnectionState PROG_NAME = ( f"{path.basename(sys.executable)} -m {MODULE_NAME}" @@ -221,25 +223,72 @@ def is_pybricks_usb(dev): # Connect to the address and run the script await hub.connect() try: - while True: - with _get_script_path(args.file) as script_path: - if args.start: - await hub.run(script_path, args.wait) - else: - await hub.download(script_path) - - if not args.wait or not args.stay_connected: - break - - resend = await questionary.select( - "Would you like to resend your code?", choices=["Resend", "Exit"] - ).ask_async() - - if resend == "Exit": - break - - except RuntimeError: - print("The hub is no longer connected.") + with _get_script_path(args.file) as script_path: + if args.start: + await hub.run(script_path, args.wait or args.stay_connected) + else: + if args.stay_connected: + hub.print_output = True + hub._enable_line_handler = True + await hub.download(script_path) + + if args.stay_connected: + response_options = ["Recompile and Run", "Recompile and Download", "Exit"] + while True: + try: + response = await hub.race_user_program_start(questionary.select("Would you like to re-compile your code?", response_options).ask_async()) + except RuntimeError as e: + + async def reconnect_hub(): + if await questionary.confirm( + "\nThe hub has been disconnected. Would you like to re-connect?").ask_async(): + if args.conntype == "ble": + print(f"Searching for {args.name or 'any hub with Pybricks service'}...") + device_or_address = await find_ble(args.name) + hub = PybricksHubBLE(device_or_address) + elif args.conntype == "usb": + device_or_address = find_usb(custom_match=is_pybricks_usb) + hub = PybricksHubUSB(device_or_address) + + await hub.connect() + # re-enable echoing of the hub's stdout + hub.print_output = True + hub._enable_line_handler = True + return hub + + else: + exit() + + if hub.status_observable.value & StatusFlag.POWER_BUTTON_PRESSED: + try: + await hub._wait_for_user_program_stop(2.1) + continue + + except RuntimeError as e: + if hub.connection_state_observable.value == ConnectionState.DISCONNECTED: + hub = await reconnect_hub() + continue + + else: + raise e + + elif hub.connection_state_observable.value == ConnectionState.DISCONNECTED: + # let terminal cool off before making a new prompt + await asyncio.sleep(0.3) + + hub = await reconnect_hub() + continue + + else: + raise e + + with _get_script_path(args.file) as script_path: + if response == response_options[0]: + await hub.run(script_path, True) + elif response == response_options[1]: + await hub.download(script_path) + else: + exit(1) finally: await hub.disconnect() @@ -247,6 +296,7 @@ def is_pybricks_usb(dev): class Flash(Tool): + def add_parser(self, subparsers: argparse._SubParsersAction): parser = subparsers.add_parser( "flash", help="flash firmware on a LEGO Powered Up device" diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 78c904e..9e4d1b4 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -678,7 +678,58 @@ async def send_block(data: bytes) -> None: if wait: await self._wait_for_user_program_stop() - async def _wait_for_user_program_stop(self): + async def race_user_program_start(self, awaitable: Awaitable[T]) -> T: + """ + Races an awaitable against the user pressing the power button of the hub. + + If the power button is pressed or the hub becomes disconnected before the awaitable is complete, a + ``RuntimeError`` is raised and the awaitable is canceled. + + Otherwise, the result of the awaitable is returned. If the awaitable + raises an exception, that exception will be raised. + + Args: + awaitable: Any awaitable such as a coroutine. + + Returns: + The result of the awaitable. + + Raises: + RuntimeError: + Thrown if the hub's power button is pressed or the hub is disconnected. + """ + awaitable_task = asyncio.ensure_future(awaitable) + + power_button_press_event = asyncio.Event() + power_button_press_task = asyncio.ensure_future(power_button_press_event.wait()) + + disconnect_event = asyncio.Event() + disconnect_task = asyncio.ensure_future(disconnect_event.wait()) + + def handle_disconnect(state: ConnectionState): + if state == ConnectionState.DISCONNECTED: + disconnect_event.set() + + def handle_power_button_press(status: StatusFlag): + if status.value & StatusFlag.POWER_BUTTON_PRESSED: + power_button_press_event.set() + + with self.status_observable.subscribe(handle_power_button_press) and self.connection_state_observable.subscribe(handle_disconnect): + done, pending = await asyncio.wait( + {awaitable_task, power_button_press_task, disconnect_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for t in pending: + t.cancel() + + if power_button_press_task in done: + raise RuntimeError("the hub's power button was pressed during operation") + elif disconnect_task in done: + raise RuntimeError("the hub was disconnected during operation") + return awaitable_task.result() + + async def _wait_for_user_program_stop(self, program_start_timeout = 1): user_program_running: asyncio.Queue[bool] = asyncio.Queue() with self.status_observable.pipe( @@ -695,7 +746,7 @@ async def _wait_for_user_program_stop(self): # for it to start try: await asyncio.wait_for( - self.race_disconnect(user_program_running.get()), 1 + self.race_disconnect(user_program_running.get()), program_start_timeout ) except asyncio.TimeoutError: # if it doesn't start, assume it was a very short lived From 87ce5bcb33608646af5bc72d6d52d174a1610824 Mon Sep 17 00:00:00 2001 From: shaggy Date: Mon, 8 Sep 2025 09:16:19 -0500 Subject: [PATCH 14/27] fix some linting issues --- pybricksdev/cli/__init__.py | 49 ++++++++++++++++++++--------- pybricksdev/connections/pybricks.py | 6 ++-- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index fceb8ed..1a307b7 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -233,21 +233,35 @@ def is_pybricks_usb(dev): await hub.download(script_path) if args.stay_connected: - response_options = ["Recompile and Run", "Recompile and Download", "Exit"] + response_options = [ + "Recompile and Run", + "Recompile and Download", + "Exit", + ] while True: try: - response = await hub.race_user_program_start(questionary.select("Would you like to re-compile your code?", response_options).ask_async()) + response = await hub.race_user_program_start( + questionary.select( + "Would you like to re-compile your code?", + response_options, + ).ask_async() + ) except RuntimeError as e: async def reconnect_hub(): if await questionary.confirm( - "\nThe hub has been disconnected. Would you like to re-connect?").ask_async(): + "\nThe hub has been disconnected. Would you like to re-connect?" + ).ask_async(): if args.conntype == "ble": - print(f"Searching for {args.name or 'any hub with Pybricks service'}...") + print( + f"Searching for {args.name or 'any hub with Pybricks service'}..." + ) device_or_address = await find_ble(args.name) hub = PybricksHubBLE(device_or_address) elif args.conntype == "usb": - device_or_address = find_usb(custom_match=is_pybricks_usb) + device_or_address = find_usb( + custom_match=is_pybricks_usb + ) hub = PybricksHubUSB(device_or_address) await hub.connect() @@ -259,20 +273,26 @@ async def reconnect_hub(): else: exit() - if hub.status_observable.value & StatusFlag.POWER_BUTTON_PRESSED: + if (hub.status_observable.value + & StatusFlag.POWER_BUTTON_PRESSED + ): try: await hub._wait_for_user_program_stop(2.1) continue except RuntimeError as e: - if hub.connection_state_observable.value == ConnectionState.DISCONNECTED: + if (hub.connection_state_observable.value + == ConnectionState.DISCONNECTED + ): hub = await reconnect_hub() continue else: raise e - elif hub.connection_state_observable.value == ConnectionState.DISCONNECTED: + elif (hub.connection_state_observable.value + == ConnectionState.DISCONNECTED + ): # let terminal cool off before making a new prompt await asyncio.sleep(0.3) @@ -283,12 +303,12 @@ async def reconnect_hub(): raise e with _get_script_path(args.file) as script_path: - if response == response_options[0]: - await hub.run(script_path, True) - elif response == response_options[1]: - await hub.download(script_path) - else: - exit(1) + if response == response_options[0]: + await hub.run(script_path, True) + elif response == response_options[1]: + await hub.download(script_path) + else: + exit(1) finally: await hub.disconnect() @@ -296,7 +316,6 @@ async def reconnect_hub(): class Flash(Tool): - def add_parser(self, subparsers: argparse._SubParsersAction): parser = subparsers.add_parser( "flash", help="flash firmware on a LEGO Powered Up device" diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 9e4d1b4..6ac6e5a 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -714,7 +714,9 @@ def handle_power_button_press(status: StatusFlag): if status.value & StatusFlag.POWER_BUTTON_PRESSED: power_button_press_event.set() - with self.status_observable.subscribe(handle_power_button_press) and self.connection_state_observable.subscribe(handle_disconnect): + with self.status_observable.subscribe( + handle_power_button_press + ) and self.connection_state_observable.subscribe(handle_disconnect): done, pending = await asyncio.wait( {awaitable_task, power_button_press_task, disconnect_task}, return_when=asyncio.FIRST_COMPLETED, @@ -729,7 +731,7 @@ def handle_power_button_press(status: StatusFlag): raise RuntimeError("the hub was disconnected during operation") return awaitable_task.result() - async def _wait_for_user_program_stop(self, program_start_timeout = 1): + async def _wait_for_user_program_stop(self, program_start_timeout=1): user_program_running: asyncio.Queue[bool] = asyncio.Queue() with self.status_observable.pipe( From 39d66758d1a335b2d751fdfa646cc684a6e7c445 Mon Sep 17 00:00:00 2001 From: shaggy Date: Mon, 8 Sep 2025 09:22:51 -0500 Subject: [PATCH 15/27] fix more linting issues --- pybricksdev/cli/__init__.py | 15 +++++++++------ pybricksdev/connections/pybricks.py | 5 +++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 1a307b7..82023d6 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -237,7 +237,7 @@ def is_pybricks_usb(dev): "Recompile and Run", "Recompile and Download", "Exit", - ] + ] while True: try: response = await hub.race_user_program_start( @@ -250,8 +250,8 @@ def is_pybricks_usb(dev): async def reconnect_hub(): if await questionary.confirm( - "\nThe hub has been disconnected. Would you like to re-connect?" - ).ask_async(): + "\nThe hub has been disconnected. Would you like to re-connect?" + ).ask_async(): if args.conntype == "ble": print( f"Searching for {args.name or 'any hub with Pybricks service'}..." @@ -273,7 +273,8 @@ async def reconnect_hub(): else: exit() - if (hub.status_observable.value + if ( + hub.status_observable.value & StatusFlag.POWER_BUTTON_PRESSED ): try: @@ -281,7 +282,8 @@ async def reconnect_hub(): continue except RuntimeError as e: - if (hub.connection_state_observable.value + if ( + hub.connection_state_observable.value == ConnectionState.DISCONNECTED ): hub = await reconnect_hub() @@ -290,7 +292,8 @@ async def reconnect_hub(): else: raise e - elif (hub.connection_state_observable.value + elif ( + hub.connection_state_observable.value == ConnectionState.DISCONNECTED ): # let terminal cool off before making a new prompt diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 6ac6e5a..96396e3 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -715,7 +715,7 @@ def handle_power_button_press(status: StatusFlag): power_button_press_event.set() with self.status_observable.subscribe( - handle_power_button_press + handle_power_button_press ) and self.connection_state_observable.subscribe(handle_disconnect): done, pending = await asyncio.wait( {awaitable_task, power_button_press_task, disconnect_task}, @@ -748,7 +748,8 @@ async def _wait_for_user_program_stop(self, program_start_timeout=1): # for it to start try: await asyncio.wait_for( - self.race_disconnect(user_program_running.get()), program_start_timeout + self.race_disconnect(user_program_running.get()), + program_start_timeout ) except asyncio.TimeoutError: # if it doesn't start, assume it was a very short lived From 31561dcc11c0b8e6f43776cfa2fab610af1a6aac Mon Sep 17 00:00:00 2001 From: shaggy Date: Mon, 8 Sep 2025 09:27:41 -0500 Subject: [PATCH 16/27] linter fix V3 --- pybricksdev/cli/__init__.py | 8 ++++---- pybricksdev/connections/pybricks.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 82023d6..401a900 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -240,10 +240,10 @@ def is_pybricks_usb(dev): ] while True: try: - response = await hub.race_user_program_start( + response = await hub.race_power_button_press( questionary.select( - "Would you like to re-compile your code?", - response_options, + "Would you like to re-compile your code?", + response_options, ).ask_async() ) except RuntimeError as e: @@ -294,7 +294,7 @@ async def reconnect_hub(): elif ( hub.connection_state_observable.value - == ConnectionState.DISCONNECTED + == ConnectionState.DISCONNECTED ): # let terminal cool off before making a new prompt await asyncio.sleep(0.3) diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 96396e3..c97d204 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -678,7 +678,7 @@ async def send_block(data: bytes) -> None: if wait: await self._wait_for_user_program_stop() - async def race_user_program_start(self, awaitable: Awaitable[T]) -> T: + async def race_power_button_press(self, awaitable: Awaitable[T]) -> T: """ Races an awaitable against the user pressing the power button of the hub. @@ -749,7 +749,7 @@ async def _wait_for_user_program_stop(self, program_start_timeout=1): try: await asyncio.wait_for( self.race_disconnect(user_program_running.get()), - program_start_timeout + program_start_timeout, ) except asyncio.TimeoutError: # if it doesn't start, assume it was a very short lived From 888aa701fb0898f6cdf9e39c20acfb1545dd8535 Mon Sep 17 00:00:00 2001 From: shaggy Date: Mon, 8 Sep 2025 09:29:41 -0500 Subject: [PATCH 17/27] linter fix V4 --- pybricksdev/cli/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 401a900..5b2f906 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -242,10 +242,10 @@ def is_pybricks_usb(dev): try: response = await hub.race_power_button_press( questionary.select( - "Would you like to re-compile your code?", + "Would you like to re-compile your code?", response_options, - ).ask_async() - ) + ).ask_async() + ) except RuntimeError as e: async def reconnect_hub(): @@ -294,7 +294,7 @@ async def reconnect_hub(): elif ( hub.connection_state_observable.value - == ConnectionState.DISCONNECTED + == ConnectionState.DISCONNECTED ): # let terminal cool off before making a new prompt await asyncio.sleep(0.3) From 5bc6a3b1dc6df39fff9d3a5357e6ff176ecf6823 Mon Sep 17 00:00:00 2001 From: shaggy Date: Mon, 8 Sep 2025 21:27:32 -0500 Subject: [PATCH 18/27] refactor and change the time to wait after power button is pressed --- pybricksdev/cli/__init__.py | 65 +++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 5b2f906..bded0ed 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -244,42 +244,53 @@ def is_pybricks_usb(dev): questionary.select( "Would you like to re-compile your code?", response_options, + default=( + response_options[0] + if args.start + else response_options[1] + ), ).ask_async() ) + with _get_script_path(args.file) as script_path: + if response == response_options[0]: + await hub.run(script_path, True) + elif response == response_options[1]: + await hub.download(script_path) + else: + exit() + except RuntimeError as e: async def reconnect_hub(): - if await questionary.confirm( + if not await questionary.confirm( "\nThe hub has been disconnected. Would you like to re-connect?" ).ask_async(): - if args.conntype == "ble": - print( - f"Searching for {args.name or 'any hub with Pybricks service'}..." - ) - device_or_address = await find_ble(args.name) - hub = PybricksHubBLE(device_or_address) - elif args.conntype == "usb": - device_or_address = find_usb( - custom_match=is_pybricks_usb - ) - hub = PybricksHubUSB(device_or_address) - - await hub.connect() - # re-enable echoing of the hub's stdout - hub.print_output = True - hub._enable_line_handler = True - return hub - - else: exit() + if args.conntype == "ble": + print( + f"Searching for {args.name or 'any hub with Pybricks service'}..." + ) + device_or_address = await find_ble(args.name) + hub = PybricksHubBLE(device_or_address) + elif args.conntype == "usb": + device_or_address = find_usb( + custom_match=is_pybricks_usb + ) + hub = PybricksHubUSB(device_or_address) + + await hub.connect() + # re-enable echoing of the hub's stdout + hub.print_output = True + hub._enable_line_handler = True + return hub + if ( hub.status_observable.value & StatusFlag.POWER_BUTTON_PRESSED ): try: - await hub._wait_for_user_program_stop(2.1) - continue + await hub._wait_for_user_program_stop(5) except RuntimeError as e: if ( @@ -287,7 +298,6 @@ async def reconnect_hub(): == ConnectionState.DISCONNECTED ): hub = await reconnect_hub() - continue else: raise e @@ -300,19 +310,10 @@ async def reconnect_hub(): await asyncio.sleep(0.3) hub = await reconnect_hub() - continue else: raise e - with _get_script_path(args.file) as script_path: - if response == response_options[0]: - await hub.run(script_path, True) - elif response == response_options[1]: - await hub.download(script_path) - else: - exit(1) - finally: await hub.disconnect() From f4dede90ce26680765f1b41654b1447b5672d664 Mon Sep 17 00:00:00 2001 From: shaggy Date: Tue, 9 Sep 2025 10:14:13 -0500 Subject: [PATCH 19/27] make custom errors for the hub disconnect and power button press events --- pybricksdev/cli/__init__.py | 88 +++++++++++------------------ pybricksdev/connections/pybricks.py | 16 +++++- 2 files changed, 46 insertions(+), 58 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index bded0ed..306fe72 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -20,8 +20,10 @@ from pybricksdev import __name__ as MODULE_NAME from pybricksdev import __version__ as MODULE_VERSION -from pybricksdev.ble.pybricks import StatusFlag -from pybricksdev.connections import ConnectionState +from pybricksdev.connections.pybricks import ( + HubDisconnectError, + HubPowerButtonPressedError, +) PROG_NAME = ( f"{path.basename(sys.executable)} -m {MODULE_NAME}" @@ -233,6 +235,29 @@ def is_pybricks_usb(dev): await hub.download(script_path) if args.stay_connected: + + async def reconnect_hub(): + if not await questionary.confirm( + "\nThe hub has been disconnected. Would you like to re-connect?" + ).ask_async(): + exit() + + if args.conntype == "ble": + print( + f"Searching for {args.name or 'any hub with Pybricks service'}..." + ) + device_or_address = await find_ble(args.name) + hub = PybricksHubBLE(device_or_address) + elif args.conntype == "usb": + device_or_address = find_usb(custom_match=is_pybricks_usb) + hub = PybricksHubUSB(device_or_address) + + await hub.connect() + # re-enable echoing of the hub's stdout + hub.print_output = True + hub._enable_line_handler = True + return hub + response_options = [ "Recompile and Run", "Recompile and Download", @@ -259,60 +284,13 @@ def is_pybricks_usb(dev): else: exit() - except RuntimeError as e: - - async def reconnect_hub(): - if not await questionary.confirm( - "\nThe hub has been disconnected. Would you like to re-connect?" - ).ask_async(): - exit() + except HubDisconnectError: + # let terminal cool off before making a new prompt + await asyncio.sleep(0.3) + hub = reconnect_hub() - if args.conntype == "ble": - print( - f"Searching for {args.name or 'any hub with Pybricks service'}..." - ) - device_or_address = await find_ble(args.name) - hub = PybricksHubBLE(device_or_address) - elif args.conntype == "usb": - device_or_address = find_usb( - custom_match=is_pybricks_usb - ) - hub = PybricksHubUSB(device_or_address) - - await hub.connect() - # re-enable echoing of the hub's stdout - hub.print_output = True - hub._enable_line_handler = True - return hub - - if ( - hub.status_observable.value - & StatusFlag.POWER_BUTTON_PRESSED - ): - try: - await hub._wait_for_user_program_stop(5) - - except RuntimeError as e: - if ( - hub.connection_state_observable.value - == ConnectionState.DISCONNECTED - ): - hub = await reconnect_hub() - - else: - raise e - - elif ( - hub.connection_state_observable.value - == ConnectionState.DISCONNECTED - ): - # let terminal cool off before making a new prompt - await asyncio.sleep(0.3) - - hub = await reconnect_hub() - - else: - raise e + except HubPowerButtonPressedError: + hub._wait_for_user_program_stop(5) finally: await hub.disconnect() diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index c97d204..5bfc472 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -65,6 +65,14 @@ T = TypeVar("T") +class HubDisconnectError(Exception): + """Raise when a hub disconnect occurs.""" + + +class HubPowerButtonPressedError(Exception): + """Raise when the hub's power button is pressed.""" + + class PybricksHub: EOL = b"\r\n" # MicroPython EOL @@ -358,7 +366,7 @@ def handle_disconnect(state: ConnectionState): t.cancel() if awaitable_task not in done: - raise RuntimeError("disconnected during operation") + raise HubDisconnectError("disconnected during operation") return awaitable_task.result() @@ -726,9 +734,11 @@ def handle_power_button_press(status: StatusFlag): t.cancel() if power_button_press_task in done: - raise RuntimeError("the hub's power button was pressed during operation") + raise HubPowerButtonPressedError( + "the hub's power button was pressed during operation" + ) elif disconnect_task in done: - raise RuntimeError("the hub was disconnected during operation") + raise HubDisconnectError("the hub was disconnected during operation") return awaitable_task.result() async def _wait_for_user_program_stop(self, program_start_timeout=1): From 52f055ff7b8b3ce69b367849ba078eb4576c3059 Mon Sep 17 00:00:00 2001 From: shaggy Date: Tue, 9 Sep 2025 10:32:42 -0500 Subject: [PATCH 20/27] add proper handling of the new hub errors in the stay-connected code --- pybricksdev/cli/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 306fe72..2444c39 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -254,6 +254,10 @@ async def reconnect_hub(): await hub.connect() # re-enable echoing of the hub's stdout + hub.log_file = None + hub.output = [] + hub._stdout_buf.clear() + hub._stdout_line_queue = asyncio.Queue() hub.print_output = True hub._enable_line_handler = True return hub @@ -284,13 +288,16 @@ async def reconnect_hub(): else: exit() + except HubPowerButtonPressedError: + try: + await hub._wait_for_user_program_stop(5) + except HubDisconnectError: + hub = await reconnect_hub() + except HubDisconnectError: # let terminal cool off before making a new prompt await asyncio.sleep(0.3) - hub = reconnect_hub() - - except HubPowerButtonPressedError: - hub._wait_for_user_program_stop(5) + hub = await reconnect_hub() finally: await hub.disconnect() From 745a588635abc26a6cecc0c92cddcedd6e430abf Mon Sep 17 00:00:00 2001 From: shaggy Date: Tue, 9 Sep 2025 20:19:29 -0500 Subject: [PATCH 21/27] minor fixes and cleanup --- pybricksdev/cli/__init__.py | 124 ++++++++++++++-------------- pybricksdev/connections/pybricks.py | 21 ++--- 2 files changed, 74 insertions(+), 71 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 2444c39..489d47b 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -230,75 +230,77 @@ def is_pybricks_usb(dev): await hub.run(script_path, args.wait or args.stay_connected) else: if args.stay_connected: + # if the user later starts the program by pressing the button on the hub, + # we still want the hub stdout to print to Python's stdout hub.print_output = True hub._enable_line_handler = True await hub.download(script_path) - if args.stay_connected: - - async def reconnect_hub(): - if not await questionary.confirm( - "\nThe hub has been disconnected. Would you like to re-connect?" - ).ask_async(): - exit() - - if args.conntype == "ble": - print( - f"Searching for {args.name or 'any hub with Pybricks service'}..." - ) - device_or_address = await find_ble(args.name) - hub = PybricksHubBLE(device_or_address) - elif args.conntype == "usb": - device_or_address = find_usb(custom_match=is_pybricks_usb) - hub = PybricksHubUSB(device_or_address) - - await hub.connect() - # re-enable echoing of the hub's stdout - hub.log_file = None - hub.output = [] - hub._stdout_buf.clear() - hub._stdout_line_queue = asyncio.Queue() - hub.print_output = True - hub._enable_line_handler = True - return hub - - response_options = [ - "Recompile and Run", - "Recompile and Download", - "Exit", - ] - while True: - try: - response = await hub.race_power_button_press( - questionary.select( - "Would you like to re-compile your code?", - response_options, - default=( - response_options[0] - if args.start - else response_options[1] - ), - ).ask_async() - ) - with _get_script_path(args.file) as script_path: - if response == response_options[0]: - await hub.run(script_path, True) - elif response == response_options[1]: - await hub.download(script_path) - else: - exit() - - except HubPowerButtonPressedError: - try: - await hub._wait_for_user_program_stop(5) - except HubDisconnectError: - hub = await reconnect_hub() + if not args.stay_connected: + exit() + + async def reconnect_hub(): + if not await questionary.confirm( + "\nThe hub has been disconnected. Would you like to re-connect?" + ).ask_async(): + exit() + if args.conntype == "ble": + print( + f"Searching for {args.name or 'any hub with Pybricks service'}..." + ) + device_or_address = await find_ble(args.name) + hub = PybricksHubBLE(device_or_address) + elif args.conntype == "usb": + device_or_address = find_usb(custom_match=is_pybricks_usb) + hub = PybricksHubUSB(device_or_address) + + await hub.connect() + # re-enable echoing of the hub's stdout + hub._enable_line_handler = True + hub.print_output = True + return hub + + response_options = [ + "Recompile and Run", + "Recompile and Download", + "Exit", + ] + while True: + try: + response = await hub.race_power_button_press( + questionary.select( + "Would you like to re-compile your code?", + response_options, + default=( + response_options[0] + if args.start + else response_options[1] + ), + ).ask_async() + ) + with _get_script_path(args.file) as script_path: + if response == response_options[0]: + await hub.run(script_path, wait=True) + elif response == response_options[1]: + await hub.download(script_path) + else: + exit() + + except HubPowerButtonPressedError: + # This means the user pressed the button on the hub to re-start the + # current program, so the menu was canceled and we are now printing + # the hub stdout until the user program ends on the hub. + try: + await hub._wait_for_user_program_stop(5) except HubDisconnectError: - # let terminal cool off before making a new prompt - await asyncio.sleep(0.3) hub = await reconnect_hub() + except HubDisconnectError: + # let terminal cool off before making a new prompt + await asyncio.sleep(0.3) + hub = await reconnect_hub() + finally: await hub.disconnect() diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 5bfc472..0eaed9b 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -65,12 +65,12 @@ T = TypeVar("T") -class HubDisconnectError(Exception): +class HubDisconnectError(RuntimeError): """Raise when a hub disconnect occurs.""" -class HubPowerButtonPressedError(Exception): - """Raise when the hub's power button is pressed.""" +class HubPowerButtonPressedError(RuntimeError): + """Raise when a task was canceled because the hub's power button was pressed.""" class PybricksHub: @@ -689,12 +689,13 @@ async def send_block(data: bytes) -> None: async def race_power_button_press(self, awaitable: Awaitable[T]) -> T: """ Races an awaitable against the user pressing the power button of the hub. - If the power button is pressed or the hub becomes disconnected before the awaitable is complete, a - ``RuntimeError`` is raised and the awaitable is canceled. - + :class:`HubPowerButtonPressedError` or :class:`HubDisconnectError` is raised and the awaitable is canceled. Otherwise, the result of the awaitable is returned. If the awaitable raises an exception, that exception will be raised. + The intended purpose of this function is to detect when + the user manually starts a program on the hub. It is used instead of the program running flag + because the hub can send info through stdout before we can detect a change in the program running flag. Args: awaitable: Any awaitable such as a coroutine. @@ -703,8 +704,8 @@ async def race_power_button_press(self, awaitable: Awaitable[T]) -> T: The result of the awaitable. Raises: - RuntimeError: - Thrown if the hub's power button is pressed or the hub is disconnected. + HubPowerButtonPressedError + HubDisconnectError """ awaitable_task = asyncio.ensure_future(awaitable) @@ -724,7 +725,7 @@ def handle_power_button_press(status: StatusFlag): with self.status_observable.subscribe( handle_power_button_press - ) and self.connection_state_observable.subscribe(handle_disconnect): + ), self.connection_state_observable.subscribe(handle_disconnect): done, pending = await asyncio.wait( {awaitable_task, power_button_press_task, disconnect_task}, return_when=asyncio.FIRST_COMPLETED, @@ -737,7 +738,7 @@ def handle_power_button_press(status: StatusFlag): raise HubPowerButtonPressedError( "the hub's power button was pressed during operation" ) - elif disconnect_task in done: + if disconnect_task in done: raise HubDisconnectError("the hub was disconnected during operation") return awaitable_task.result() From 0633bfddaeb1641fb388850930d24e92d0d5f444 Mon Sep 17 00:00:00 2001 From: shaggy Date: Tue, 9 Sep 2025 20:29:20 -0500 Subject: [PATCH 22/27] change uses of the exit function to return instead when possible --- pybricksdev/cli/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 489d47b..e61248e 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -237,7 +237,7 @@ def is_pybricks_usb(dev): await hub.download(script_path) if not args.stay_connected: - exit() + return async def reconnect_hub(): if not await questionary.confirm( @@ -285,7 +285,7 @@ async def reconnect_hub(): elif response == response_options[1]: await hub.download(script_path) else: - exit() + return except HubPowerButtonPressedError: # This means the user pressed the button on the hub to re-start the From 2fa86cb8d9d132551a8369ac668601ac1d7a73dd Mon Sep 17 00:00:00 2001 From: shaggy Date: Wed, 10 Sep 2025 07:43:31 -0500 Subject: [PATCH 23/27] fix a windows specific issue where the wait_for_user_program_stop function doesn't detect the hub being turned off properly --- pybricksdev/cli/__init__.py | 36 ++++++++++++++++++++--------- pybricksdev/connections/pybricks.py | 32 ++++++++++++------------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index e61248e..2aed4bb 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -268,16 +268,18 @@ async def reconnect_hub(): ] while True: try: - response = await hub.race_power_button_press( - questionary.select( - "Would you like to re-compile your code?", - response_options, - default=( - response_options[0] - if args.start - else response_options[1] - ), - ).ask_async() + response = await hub.race_disconnect( + hub.race_power_button_press( + questionary.select( + "Would you like to re-compile your code?", + response_options, + default=( + response_options[0] + if args.start + else response_options[1] + ), + ).ask_async() + ) ) with _get_script_path(args.file) as script_path: if response == response_options[0]: @@ -292,10 +294,22 @@ async def reconnect_hub(): # current program, so the menu was canceled and we are now printing # the hub stdout until the user program ends on the hub. try: - await hub._wait_for_user_program_stop(5) + await hub._wait_for_user_program_stop( + 2.25, raise_error_on_timeout=True + ) + except HubDisconnectError: hub = await reconnect_hub() + except asyncio.TimeoutError: + # On windows, it takes significantly longer + # for the device to register that the hub was + # disconnected. If _wait_for_user_program_stop + # throws a timeout error, we can assume that the + # hub was disconnected. + await hub.disconnect() + hub = await reconnect_hub() + except HubDisconnectError: # let terminal cool off before making a new prompt await asyncio.sleep(0.3) diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 91f713c..50e4a2b 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -717,24 +717,20 @@ async def race_power_button_press(self, awaitable: Awaitable[T]) -> T: power_button_press_event = asyncio.Event() power_button_press_task = asyncio.ensure_future(power_button_press_event.wait()) - disconnect_event = asyncio.Event() - disconnect_task = asyncio.ensure_future(disconnect_event.wait()) - - def handle_disconnect(state: ConnectionState): - if state == ConnectionState.DISCONNECTED: - disconnect_event.set() - def handle_power_button_press(status: StatusFlag): if status.value & StatusFlag.POWER_BUTTON_PRESSED: power_button_press_event.set() - with self.status_observable.subscribe( - handle_power_button_press - ), self.connection_state_observable.subscribe(handle_disconnect): - done, pending = await asyncio.wait( - {awaitable_task, power_button_press_task, disconnect_task}, - return_when=asyncio.FIRST_COMPLETED, - ) + with self.status_observable.subscribe(handle_power_button_press): + try: + done, pending = await asyncio.wait( + {awaitable_task, power_button_press_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + except BaseException: + awaitable_task.cancel() + power_button_press_task.cancel() + raise for t in pending: t.cancel() @@ -743,11 +739,11 @@ def handle_power_button_press(status: StatusFlag): raise HubPowerButtonPressedError( "the hub's power button was pressed during operation" ) - if disconnect_task in done: - raise HubDisconnectError("the hub was disconnected during operation") return awaitable_task.result() - async def _wait_for_user_program_stop(self, program_start_timeout=1): + async def _wait_for_user_program_stop( + self, program_start_timeout=1, raise_error_on_timeout=False + ): user_program_running: asyncio.Queue[bool] = asyncio.Queue() with self.status_observable.pipe( @@ -768,6 +764,8 @@ async def _wait_for_user_program_stop(self, program_start_timeout=1): program_start_timeout, ) except asyncio.TimeoutError: + if raise_error_on_timeout: + raise # if it doesn't start, assume it was a very short lived # program and we just missed the status message logger.debug( From 3bec955fad7ae16304b0e6c06183218df9b5197d Mon Sep 17 00:00:00 2001 From: shaggy Date: Wed, 10 Sep 2025 08:40:29 -0500 Subject: [PATCH 24/27] add a special case in the stay-connected implementation for when the file is sys.stdin --- pybricksdev/cli/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index 2aed4bb..fede41a 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -268,6 +268,15 @@ async def reconnect_hub(): ] while True: try: + if args.file is sys.stdin: + await hub.race_disconnect( + hub.race_power_button_press( + questionary.press_any_key_to_continue( + "The hub will stay connected and echo its output to the terminal. Press any key to exit." + ).ask_async() + ) + ) + return response = await hub.race_disconnect( hub.race_power_button_press( questionary.select( @@ -302,7 +311,7 @@ async def reconnect_hub(): hub = await reconnect_hub() except asyncio.TimeoutError: - # On windows, it takes significantly longer + # On windows, it can take significantly longer # for the device to register that the hub was # disconnected. If _wait_for_user_program_stop # throws a timeout error, we can assume that the From f088c1a4a77551110ab4e59284ad2451400bbdc1 Mon Sep 17 00:00:00 2001 From: shaggy Date: Wed, 10 Sep 2025 10:37:42 -0500 Subject: [PATCH 25/27] minor stability changes to the behavior when using the stay-connected arg --- pybricksdev/cli/__init__.py | 14 ++-------- pybricksdev/connections/pybricks.py | 40 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/pybricksdev/cli/__init__.py b/pybricksdev/cli/__init__.py index fede41a..a499bd3 100644 --- a/pybricksdev/cli/__init__.py +++ b/pybricksdev/cli/__init__.py @@ -303,22 +303,12 @@ async def reconnect_hub(): # current program, so the menu was canceled and we are now printing # the hub stdout until the user program ends on the hub. try: - await hub._wait_for_user_program_stop( - 2.25, raise_error_on_timeout=True - ) + await hub._wait_for_power_button_release() + await hub._wait_for_user_program_stop() except HubDisconnectError: hub = await reconnect_hub() - except asyncio.TimeoutError: - # On windows, it can take significantly longer - # for the device to register that the hub was - # disconnected. If _wait_for_user_program_stop - # throws a timeout error, we can assume that the - # hub was disconnected. - await hub.disconnect() - hub = await reconnect_hub() - except HubDisconnectError: # let terminal cool off before making a new prompt await asyncio.sleep(0.3) diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 50e4a2b..6434652 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -741,6 +741,46 @@ def handle_power_button_press(status: StatusFlag): ) return awaitable_task.result() + async def _wait_for_power_button_release( + self, program_start_timeout=1, raise_error_on_timeout=False + ): + power_button_pressed: asyncio.Queue[bool] = asyncio.Queue() + + with self.status_observable.pipe( + op.map(lambda s: bool(s & StatusFlag.POWER_BUTTON_PRESSED)), + op.distinct_until_changed(), + ).subscribe(lambda s: power_button_pressed.put_nowait(s)): + # The first item in the queue is the current status. The status + # could change before or after the last checksum is received, + # so this could be either true or false. + is_pressed = await self.race_disconnect(power_button_pressed.get()) + + if not is_pressed: + # If the button isn't already pressed, + # wait a short time for it to become pressed + try: + await asyncio.wait_for( + self.race_disconnect(power_button_pressed.get()), + program_start_timeout, + ) + except asyncio.TimeoutError: + if raise_error_on_timeout: + raise + # If the button never shows as pressed, + # assume that we just missed the status flag + logger.debug( + "timed out waiting for power button press, assuming is was a short press" + ) + return + + # At this point, we know the button is pressed, so the + # next item in the queue must indicate the button has + # been released. + is_pressed = await self.race_disconnect(power_button_pressed.get()) + + # maybe catch mistake if the code is changed + assert not is_pressed + async def _wait_for_user_program_stop( self, program_start_timeout=1, raise_error_on_timeout=False ): From 7429c39788168525b014d3844ed92f83cd3cd2ee Mon Sep 17 00:00:00 2001 From: shaggy Date: Wed, 10 Sep 2025 15:36:36 -0500 Subject: [PATCH 26/27] remove unnecessary parameters from the hub wait_for methods --- pybricksdev/connections/pybricks.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/pybricksdev/connections/pybricks.py b/pybricksdev/connections/pybricks.py index 6434652..709f238 100644 --- a/pybricksdev/connections/pybricks.py +++ b/pybricksdev/connections/pybricks.py @@ -741,9 +741,7 @@ def handle_power_button_press(status: StatusFlag): ) return awaitable_task.result() - async def _wait_for_power_button_release( - self, program_start_timeout=1, raise_error_on_timeout=False - ): + async def _wait_for_power_button_release(self): power_button_pressed: asyncio.Queue[bool] = asyncio.Queue() with self.status_observable.pipe( @@ -761,11 +759,9 @@ async def _wait_for_power_button_release( try: await asyncio.wait_for( self.race_disconnect(power_button_pressed.get()), - program_start_timeout, + 1, ) except asyncio.TimeoutError: - if raise_error_on_timeout: - raise # If the button never shows as pressed, # assume that we just missed the status flag logger.debug( @@ -781,9 +777,7 @@ async def _wait_for_power_button_release( # maybe catch mistake if the code is changed assert not is_pressed - async def _wait_for_user_program_stop( - self, program_start_timeout=1, raise_error_on_timeout=False - ): + async def _wait_for_user_program_stop(self): user_program_running: asyncio.Queue[bool] = asyncio.Queue() with self.status_observable.pipe( @@ -801,11 +795,9 @@ async def _wait_for_user_program_stop( try: await asyncio.wait_for( self.race_disconnect(user_program_running.get()), - program_start_timeout, + 1, ) except asyncio.TimeoutError: - if raise_error_on_timeout: - raise # if it doesn't start, assume it was a very short lived # program and we just missed the status message logger.debug( From 76a0b89067e9fac646da59b4313102ddbb844daf Mon Sep 17 00:00:00 2001 From: shaggy Date: Wed, 10 Sep 2025 21:06:03 -0500 Subject: [PATCH 27/27] CHANGELOG.md: Add entry for the --stay-connected arg --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48bcb4d..bb159f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added the `--stay-connected` arg to the `pybricksdev run` + command, allowing re-compiling and running the input file. Also echoes + the hub's output to the console when manually running a program. + ([pybricksdev#122]) + +[pybricksdev#122]: https://github.com/pybricks/pybricksdev/pull/122 + + ## [2.0.1] - 2025-08-11 ### Fixed