From 7a14ebdabe6fb61e486d7e64b9081b3bee41f33a Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Wed, 3 Jun 2026 22:08:39 +0800 Subject: [PATCH] fix(usb): add transport layer support for sending ISO over Bulk Out This change adds the missing transport-side support for sending HCI ISO Data packets over the default Bulk Out endpoint when Isochronous endpoints are not enabled. - Handles HCI_ISO_DATA_PACKET (0x05) in both `usb` and `pyusb` transports. - Adds unit tests to verify the routing behavior. TAG=agy CONV=5502c76b-b272-4e43-a0b9-425a23cf137e --- bumble/transport/pyusb.py | 3 ++ bumble/transport/usb.py | 19 ++++++++ tests/usb_test.py | 95 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 tests/usb_test.py diff --git a/bumble/transport/pyusb.py b/bumble/transport/pyusb.py index 00665d0..4f4549a 100644 --- a/bumble/transport/pyusb.py +++ b/bumble/transport/pyusb.py @@ -104,6 +104,9 @@ async def open_pyusb_transport(spec: str) -> Transport: 0, packet[1:], ) + elif packet_type == hci.HCI_ISO_DATA_PACKET: + # Workaround: Send ISO packets over Bulk Out + self.device.write(USB_ENDPOINT_ACL_OUT, packet[1:]) else: logger.warning( color(f'unsupported packet type {packet_type}', 'red') diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py index b6e684f..fa1f046 100644 --- a/bumble/transport/usb.py +++ b/bumble/transport/usb.py @@ -336,6 +336,25 @@ class UsbPacketSink: ) self.isochronous_out_transfer.submit() submitted = True + elif packet_type == hci.HCI_ISO_DATA_PACKET: + if self.isochronous_out_transfer is None: + # Workaround: Send ISO packets over Bulk Out when Isochronous endpoints are not enabled + self.bulk_or_control_out_transfer.setBulk( + self.bulk_out.getAddress(), + packet_payload, + callback=self.transfer_callback, + ) + self.bulk_or_control_out_transfer.submit() + submitted = True + else: + logger.warning( + color( + 'ISO packets over Isochronous endpoints not supported yet', + 'red', + ) + ) + self.out_transfer_ready.release() + continue else: logger.warning( color(f'unsupported packet type {packet_type}', 'red') diff --git a/tests/usb_test.py b/tests/usb_test.py new file mode 100644 index 0000000..1f0003d --- /dev/null +++ b/tests/usb_test.py @@ -0,0 +1,95 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from unittest import mock + +import pytest + +from bumble import hci +from bumble.transport import usb + + +@pytest.mark.asyncio +async def test_usb_packet_sink_iso_routing(): + # Mock usb1 device and endpoints + mock_device = mock.Mock() + mock_bulk_out = mock.Mock() + mock_bulk_out.getAddress.return_value = 0x02 + + # Scenario 1: Isochronous endpoints are not enabled (isochronous_out is None) + mock_transfer = mock.Mock() + mock_device.getTransfer.return_value = mock_transfer + + sink = usb.UsbPacketSink(mock_device, mock_bulk_out, isochronous_out=None) + sink.start() + + # Send HCI_ISO_DATA_PACKET + iso_packet = bytes([hci.HCI_ISO_DATA_PACKET, 0x01, 0x02, 0x03]) + sink.on_packet(iso_packet) + + # Yield control to let the queue processor run + await asyncio.sleep(0.01) + + # Verify it was sent via bulk transfer + mock_transfer.setBulk.assert_called_once_with( + 0x02, + bytes([0x01, 0x02, 0x03]), + callback=sink.transfer_callback, + ) + mock_transfer.submit.assert_called_once() + + if sink.queue_task: + sink.queue_task.cancel() + try: + await sink.queue_task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_usb_packet_sink_iso_routing_with_iso_endpoint(): + # Mock usb1 device and endpoints + mock_device = mock.Mock() + mock_bulk_out = mock.Mock() + mock_bulk_out.getAddress.return_value = 0x02 + mock_iso_out = mock.Mock() + mock_iso_out.getMaxPacketSize.return_value = 64 + + # Scenario 2: Isochronous endpoints are enabled + mock_transfer_bulk = mock.Mock() + mock_transfer_iso = mock.Mock() + + # getTransfer is called twice: once for bulk_or_control and once for isochronous + mock_device.getTransfer.side_effect = [mock_transfer_bulk, mock_transfer_iso] + + sink = usb.UsbPacketSink(mock_device, mock_bulk_out, isochronous_out=mock_iso_out) + sink.start() + + # Send HCI_ISO_DATA_PACKET + iso_packet = bytes([hci.HCI_ISO_DATA_PACKET, 0x01, 0x02, 0x03]) + sink.on_packet(iso_packet) + + # Yield control to let the queue processor run + await asyncio.sleep(0.01) + + # Verify it was NOT sent via bulk transfer + mock_transfer_bulk.setBulk.assert_not_called() + + if sink.queue_task: + sink.queue_task.cancel() + try: + await sink.queue_task + except asyncio.CancelledError: + pass