Skip to content

Commit b9e8e1e

Browse files
nkarstensdlech
authored andcommitted
Add support for USB connections
Adds a new subclass of PybricksHub that manages USB connections. Co-developed-by: David Lechner <[email protected]> Signed-off-by: Nate Karstens <[email protected]>
1 parent 3d6cf82 commit b9e8e1e

File tree

5 files changed

+222
-7
lines changed

5 files changed

+222
-7
lines changed

CHANGELOG.md

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

77
## [Unreleased]
88

9+
### Added
10+
- Partial/experimental support for `pybricksdev run usb`.
11+
912
### Fixed
1013
- Fix crash when running `pybricksdev run ble -` (bug introduced in alpha.49).
1114

pybricksdev/cli/__init__.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,11 @@ def add_parser(self, subparsers: argparse._SubParsersAction):
171171
)
172172

173173
async def run(self, args: argparse.Namespace):
174-
from pybricksdev.ble import find_device
175-
from pybricksdev.connections.ev3dev import EV3Connection
176-
from pybricksdev.connections.lego import REPLHub
177-
from pybricksdev.connections.pybricks import PybricksHubBLE
178174

179175
# Pick the right connection
180176
if args.conntype == "ssh":
177+
from pybricksdev.connections.ev3dev import EV3Connection
178+
181179
# So it's an ev3dev
182180
if args.name is None:
183181
print("--name is required for SSH connections", file=sys.stderr)
@@ -186,13 +184,46 @@ async def run(self, args: argparse.Namespace):
186184
device_or_address = socket.gethostbyname(args.name)
187185
hub = EV3Connection(device_or_address)
188186
elif args.conntype == "ble":
187+
from pybricksdev.ble import find_device as find_ble
188+
from pybricksdev.connections.pybricks import PybricksHubBLE
189+
189190
# It is a Pybricks Hub with BLE. Device name or address is given.
190191
print(f"Searching for {args.name or 'any hub with Pybricks service'}...")
191-
device_or_address = await find_device(args.name)
192+
device_or_address = await find_ble(args.name)
192193
hub = PybricksHubBLE(device_or_address)
193-
194194
elif args.conntype == "usb":
195-
hub = REPLHub()
195+
from usb.core import find as find_usb
196+
197+
from pybricksdev.connections.pybricks import PybricksHubUSB
198+
from pybricksdev.usb import (
199+
LEGO_USB_VID,
200+
MINDSTORMS_INVENTOR_USB_PID,
201+
SPIKE_ESSENTIAL_USB_PID,
202+
SPIKE_PRIME_USB_PID,
203+
)
204+
205+
def is_pybricks_usb(dev):
206+
return (
207+
(dev.idVendor == LEGO_USB_VID)
208+
and (
209+
dev.idProduct
210+
in [
211+
SPIKE_PRIME_USB_PID,
212+
SPIKE_ESSENTIAL_USB_PID,
213+
MINDSTORMS_INVENTOR_USB_PID,
214+
]
215+
)
216+
and dev.product.endswith("Pybricks")
217+
)
218+
219+
device_or_address = find_usb(custom_match=is_pybricks_usb)
220+
221+
if device_or_address is not None:
222+
hub = PybricksHubUSB(device_or_address)
223+
else:
224+
from pybricksdev.connections.lego import REPLHub
225+
226+
hub = REPLHub()
196227
else:
197228
raise ValueError(f"Unknown connection type: {args.conntype}")
198229

pybricksdev/connections/pybricks.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import struct
99
from typing import Awaitable, Callable, List, Optional, TypeVar
10+
from uuid import UUID
1011

1112
import reactivex.operators as op
1213
import semver
@@ -17,6 +18,10 @@
1718
from reactivex.subject import BehaviorSubject, Subject
1819
from tqdm.auto import tqdm
1920
from tqdm.contrib.logging import logging_redirect_tqdm
21+
from usb.control import get_descriptor
22+
from usb.core import Device as USBDevice
23+
from usb.core import Endpoint, USBTimeoutError
24+
from usb.util import ENDPOINT_IN, ENDPOINT_OUT, endpoint_direction, find_descriptor
2025

2126
from pybricksdev.ble.lwp3.bytecodes import HubKind
2227
from pybricksdev.ble.nus import NUS_RX_UUID, NUS_TX_UUID
@@ -38,6 +43,10 @@
3843
from pybricksdev.connections import ConnectionState
3944
from pybricksdev.tools import chunk
4045
from pybricksdev.tools.checksum import xor_bytes
46+
from pybricksdev.usb.pybricks import (
47+
PybricksUsbInEpMessageType,
48+
PybricksUsbOutEpMessageType,
49+
)
4150

4251
logger = logging.getLogger(__name__)
4352

@@ -707,3 +716,142 @@ async def write_gatt_char(self, uuid: str, data, response: bool) -> None:
707716

708717
async def start_notify(self, uuid: str, callback: Callable) -> None:
709718
return await self._client.start_notify(uuid, callback)
719+
720+
721+
class PybricksHubUSB(PybricksHub):
722+
_device: USBDevice
723+
_ep_in: Endpoint
724+
_ep_out: Endpoint
725+
_notify_callbacks: dict[str, Callable] = {}
726+
_monitor_task: asyncio.Task
727+
728+
def __init__(self, device: USBDevice):
729+
super().__init__()
730+
self._device = device
731+
self._response_queue = asyncio.Queue[bytes]()
732+
733+
async def _client_connect(self) -> bool:
734+
# Reset is essential to ensure endpoints are in a good state.
735+
self._device.reset()
736+
self._device.set_configuration()
737+
738+
# Save input and output endpoints
739+
cfg = self._device.get_active_configuration()
740+
intf = cfg[(0, 0)]
741+
self._ep_in = find_descriptor(
742+
intf,
743+
custom_match=lambda e: endpoint_direction(e.bEndpointAddress)
744+
== ENDPOINT_IN,
745+
)
746+
self._ep_out = find_descriptor(
747+
intf,
748+
custom_match=lambda e: endpoint_direction(e.bEndpointAddress)
749+
== ENDPOINT_OUT,
750+
)
751+
752+
# There is 1 byte overhead for PybricksUsbMessageType
753+
self._max_write_size = self._ep_out.wMaxPacketSize - 1
754+
755+
# Get length of BOS descriptor
756+
bos_descriptor = get_descriptor(self._device, 5, 0x0F, 0)
757+
(ofst, _, bos_len, _) = struct.unpack("<BBHB", bos_descriptor)
758+
759+
# Get full BOS descriptor
760+
bos_descriptor = get_descriptor(self._device, bos_len, 0x0F, 0)
761+
762+
while ofst < bos_len:
763+
(len, desc_type, cap_type) = struct.unpack_from(
764+
"<BBB", bos_descriptor, offset=ofst
765+
)
766+
767+
if desc_type != 0x10:
768+
logger.error("Expected Device Capability descriptor")
769+
exit(1)
770+
771+
# Look for platform descriptors
772+
if cap_type == 0x05:
773+
uuid_bytes = bos_descriptor[ofst + 4 : ofst + 4 + 16]
774+
uuid_str = str(UUID(bytes_le=bytes(uuid_bytes)))
775+
776+
if uuid_str == FW_REV_UUID:
777+
fw_version = bytearray(bos_descriptor[ofst + 20 : ofst + len])
778+
self.fw_version = Version(fw_version.decode())
779+
780+
elif uuid_str == SW_REV_UUID:
781+
self._protocol_version = bytearray(
782+
bos_descriptor[ofst + 20 : ofst + len]
783+
)
784+
785+
elif uuid_str == PYBRICKS_HUB_CAPABILITIES_UUID:
786+
caps = bytearray(bos_descriptor[ofst + 20 : ofst + len])
787+
(
788+
_,
789+
self._capability_flags,
790+
self._max_user_program_size,
791+
) = unpack_hub_capabilities(caps)
792+
793+
ofst += len
794+
795+
self._monitor_task = asyncio.create_task(self._monitor_usb())
796+
797+
return True
798+
799+
async def _client_disconnect(self) -> bool:
800+
self._monitor_task.cancel()
801+
self._handle_disconnect()
802+
803+
async def read_gatt_char(self, uuid: str) -> bytearray:
804+
# Most stuff is available via other properties due to reading BOS
805+
# descriptor during connect.
806+
raise NotImplementedError
807+
808+
async def write_gatt_char(self, uuid: str, data, response: bool) -> None:
809+
if uuid.lower() != PYBRICKS_COMMAND_EVENT_UUID:
810+
raise ValueError("Only Pybricks command event UUID is supported")
811+
812+
if not response:
813+
raise ValueError("Response is required for USB")
814+
815+
self._ep_out.write(bytes([PybricksUsbOutEpMessageType.COMMAND]) + data)
816+
# FIXME: This needs to race with hub disconnect, and could also use a
817+
# timeout, otherwise it blocks forever. Pyusb doesn't currently seem to
818+
# have any disconnect callback.
819+
reply = await self._response_queue.get()
820+
821+
# REVISIT: could look up status error code and convert to string,
822+
# although BLE doesn't do that either.
823+
if int.from_bytes(reply[:4], "little") != 0:
824+
raise RuntimeError(f"Write failed: {reply[0]}")
825+
826+
async def start_notify(self, uuid: str, callback: Callable) -> None:
827+
# TODO: need to send subscribe message over USB
828+
self._notify_callbacks[uuid] = callback
829+
830+
async def _monitor_usb(self):
831+
loop = asyncio.get_running_loop()
832+
833+
while True:
834+
msg = await loop.run_in_executor(None, self._read_usb)
835+
836+
if msg is None:
837+
continue
838+
839+
if len(msg) == 0:
840+
logger.warning("Empty USB message")
841+
continue
842+
843+
if msg[0] == PybricksUsbInEpMessageType.RESPONSE:
844+
self._response_queue.put_nowait(msg[1:])
845+
elif msg[0] == PybricksUsbInEpMessageType.EVENT:
846+
callback = self._notify_callbacks.get(PYBRICKS_COMMAND_EVENT_UUID)
847+
if callback:
848+
callback(None, msg[1:])
849+
else:
850+
logger.warning("Unknown USB message type: %d", msg[0])
851+
852+
def _read_usb(self) -> bytes | None:
853+
try:
854+
msg = self._ep_in.read(self._ep_in.wMaxPacketSize)
855+
return msg
856+
except USBTimeoutError:
857+
return None

pybricksdev/usb/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,8 @@
66
EV3_USB_PID = 0x0005
77
EV3_BOOTLOADER_USB_PID = 0x0006
88
SPIKE_PRIME_DFU_USB_PID = 0x0008
9+
SPIKE_PRIME_USB_PID = 0x0009
910
SPIKE_ESSENTIAL_DFU_USB_PID = 0x000C
11+
SPIKE_ESSENTIAL_USB_PID = 0x000D
12+
MINDSTORMS_INVENTOR_USB_PID = 0x0010
1013
MINDSTORMS_INVENTOR_DFU_USB_PID = 0x0011

pybricksdev/usb/pybricks.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2025 The Pybricks Authors
3+
4+
"""
5+
Pybricks-specific USB protocol.
6+
"""
7+
8+
from enum import IntEnum
9+
10+
11+
class PybricksUsbInEpMessageType(IntEnum):
12+
RESPONSE = 1
13+
"""
14+
Analogous to BLE status response.
15+
"""
16+
EVENT = 2
17+
"""
18+
Analogous to BLE notification.
19+
"""
20+
21+
22+
class PybricksUsbOutEpMessageType(IntEnum):
23+
SUBSCRIBE = 1
24+
"""
25+
Analogous to BLE Client Characteristic Configuration Descriptor (CCCD).
26+
"""
27+
COMMAND = 2
28+
"""
29+
Analogous to BLE write without response.
30+
"""

0 commit comments

Comments
 (0)