Skip to content

Conversation

@jaguilar
Copy link
Contributor

@jaguilar jaguilar commented Dec 7, 2025

These commits allow executing real bluetooth code using virtualhub combined with an appropriate dongle. This has been tested with TP Link UB500, however, it should work with any USB dongle for which an appropriate init script exists.

Talking of init scripts, this pull request also contains a tool that collects the most common bluetooth firmwares into ~/.cache/pybricks/virtualhub/bt_firmware. Given the dongle referenced above, and after running the provided tool (pybricks-micropython/tools/collect_bt_firmware.py), bricks/virtualhub/build-debug/firmware.elf tests/virtualhub/basics/hello.py will emit logs like the following:

Tail of virtualhub.elf log

HCI in packet type: 04, len: 6
hci.c.2226: Command complete for expected opcode 0c52 at substate 58
hci.c.1777: hci_initializing_run: substate 58, can send 1
HCI out packet type: 01, len: 5
HCI in packet type: 04, len: 2
hci.c.1777: hci_initializing_run: substate 58, can send 0
HCI in packet type: 04, len: 6
hci.c.2226: Command complete for expected opcode 080f at substate 58
hci.c.1777: hci_initializing_run: substate 58, can send 1
HCI out packet type: 01, len: 5
HCI in packet type: 04, len: 2
hci.c.1777: hci_initializing_run: substate 58, can send 0
HCI in packet type: 04, len: 6
hci.c.2226: Command complete for expected opcode 0c18 at substate 58
hci.c.1777: hci_initializing_run: substate 58, can send 1
HCI out packet type: 01, len: 4
HCI in packet type: 04, len: 2
hci.c.1777: hci_initializing_run: substate 58, can send 0
HCI in packet type: 04, len: 6
HCI in packet type: 04, len: 4
hci.c.2226: Command complete for expected opcode 0c1a at substate 58
hci.c.1777: hci_initializing_run: substate 58, can send 1
HCI out packet type: 01, len: 10
HCI in packet type: 04, len: 2
hci.c.1777: hci_initializing_run: substate 58, can send 0
HCI in packet type: 04, len: 6
hci.c.2226: Command complete for expected opcode 200b at substate 58
hci.c.1777: hci_initializing_run: substate 58, can send 1
hci.c.1770: hci_init_done -> HCI_STATE_WORKING
hci.c.7436: BTSTACK_EVENT_STATE 2
HCI in packet type: 04, len: 3
hci.c.5055: hci_power_control: 0, current mode 2
hci.c.7436: BTSTACK_EVENT_STATE 3
HCI in packet type: 04, len: 3
sm.c.3756: SM: reset state
hci.c.5097: HCI_STATE_HALTING, substate 3f

hci.c.5232: HCI_STATE_HALTING: wait 50 ms
sm.c.3715: HCI Working!
sm.c.4810: sm: generate new ec key
hci.c.8257: hci_le_set_own_address_type: old 1, new 1
sm.c.711: gap_random_address_trigger, state 0
hci.c.5097: HCI_STATE_HALTING, substate 44

hci.c.5248: HCI_STATE_HALTING, calling off
hci.c.4823: hci_power_control_off
hci_transport_h2_libusb.c.1262: usb_close
hci_transport_h2_libusb.c.426: shutdown, transfer 0x5555559554a0
hci_transport_h2_libusb.c.426: shutdown, transfer 0x555555963580
hci_transport_h2_libusb.c.426: shutdown, transfer 0x555555958b40
hci_transport_h2_libusb.c.426: shutdown, transfer 0x5555559520c0
hci_transport_h2_libusb.c.426: shutdown, transfer 0x555555951a10
hci_transport_h2_libusb.c.426: shutdown, transfer 0x5555559673e0
hci_transport_h2_libusb.c.1322: Libusb shutdown complete
hci.c.4828: hci_power_control_off - hci_transport closed
hci.c.4835: hci_power_control_off - control closed
hci.c.5253: HCI_STATE_HALTING, emitting state
hci.c.7436: BTSTACK_EVENT_STATE 0
HCI in packet type: 04, len: 3
sm.c.3756: SM: reset state
hci.c.5255: HCI_STATE_HALTING, done

The best way to find a supported dongle is:

  1. Run ./tools/collect_bt_firmware.py
  2. Examine the downloaded drivers. Check whether the init script for your dongle is already present. If so, things should already work.
  3. If not, go buy that TP Link dongle, or some other dongle that is well-supported on Linux.
  4. Don't forget to add your Bluetooth dongle to your udev rules. Here's what mine looks like:
SUBSYSTEM=="usb", ATTRS{idVendor}=="2357", ATTRS{idProduct}=="0604", MODE="666", TAG+="uaccess"

This has been tested on Windows with WSL and usb-ipd-win.

@jaguilar jaguilar force-pushed the virtualhub-bluetooth branch 2 times, most recently from bb537a4 to 9aea25d Compare December 7, 2025 20:15
@jaguilar
Copy link
Contributor Author

jaguilar commented Dec 7, 2025

Okay, so this does work on a system with libusb and pkg-config installed. However, I see now that the test host does not have these libraries. We can either vendor in libusb, or we can install it in the test runner. What is the preferred solution?

@laurensvalk
Copy link
Member

Nice! I hope to try this out this week.

Re: CI: Normally installing is preferred but in this case we don't need either.

The virtual hub had both a mock usb and mock Bluetooth driver, with only the latter enabled by default.

Since the Bluetooth driver will now be a real driver and the CI won't need to talk to real hardware, we can have the virtual hub use the USB mock driver to simulate stdout instead.

For local testing we can have both enabled. The mock USB driver will still provide stdout to the host terminal which is nice as a sanity check, and the Bluetooth driver will also provide it if something is connected.

@laurensvalk
Copy link
Member

laurensvalk commented Dec 8, 2025

It's probably just me, but I wouldn't ordinarily run a script downloading things with regexes somewhere in my home directory with a nonzero history of bricking something 😄

I suppose we could have a folder bluetooth_firmware in our repository and add it to .gitignore? Downloading with a direct link (from a particular git sha) to a known working version would be nice, but I don't mind going through the repositories to find it.

The BTstack posix example has a way of specifying which device to use. I expect most test cases to use two dongles: one for the virtual hub, and one for the OS to test e.g. rfcomm or Pybricks Code. Could we do that with an environment variable? How does it currently determine which one to use?

@jaguilar
Copy link
Contributor Author

jaguilar commented Dec 8, 2025

It's probably just me, but I wouldn't ordinarily run a script downloading things with regexes somewhere in my home directory with a nonzero history of bricking something 😄

Ahaha, but in my case it bricked my dongle because I didn't just use the firmwares in this directory, I renamed one of them. Ultimately the risky part of the thing is that the firmwares are used by name and the btstack chipsets don't seem to have a good way to validate that a firmware file is correct for a module before sending it down. And/or that there is a module that will accept a firmware that will brick it. That risk remains whether the files are in the home directory or elsewhere.

I suppose we could have a folder bluetooth_firmware in our repository and add it to .gitignore? Downloading with a direct link (from a particular git sha) to a known working version would be nice, but I don't mind going through the repositories to find it.

I can checkout to a particular git sha and/or tag in my download script. That would be fine. On the other hand, every time a new chipset comes out it would be a mysterious bug for the first person who tries to use that chipset with our test bench. Up to you.

The BTstack posix example has a way of specifying which device to use. I expect most test cases to use two dongles: one for the virtual hub, and one for the OS to test e.g. rfcomm or Pybricks Code. Could we do that with an environment variable? How does it currently determine which one to use?

hci_transport_usb_set_path is the API. I don't know exactly how to use it but we can definitely finagle something. Currently it just uses the first dongle it finds.

@jaguilar jaguilar force-pushed the virtualhub-bluetooth branch 2 times, most recently from a807400 to 0f67ca8 Compare December 8, 2025 22:50
collect_bt_patches.py collects bluetooth module firmware
patch files for the most popular Broadcom and Realtek
modules into a directory where they can be used by the
virtualhub for initializing bluetooth modules.
- Changes pbdrv_bluetooth_btstack_set_chipset to convey all necessary
  information to set the correct chipset both from the read local
  version information command as well as events from the USB subsystem.
- Adds a POSIX implementation for pbdrv_bluetooth_btstack_set_chipset.
  This supports the most common Realtek and Broadcom chipsets, which
  comprise the vast majority of USB dongles.
- Sets up the virtualhub platform to use this chipset.
- Adjusts the runloop to check for readability and writability of
  file descriptors, which is required for the libusb transport.
- Implements HCI logging in bluetooth BTStack.
- Adds a stderr version of uart_debug_first_port.
- (Revert?) Enables debug logging for virtualhub
  bluetooth.
@jaguilar jaguilar force-pushed the virtualhub-bluetooth branch from 0f67ca8 to 1bca902 Compare December 12, 2025 05:44
@jaguilar
Copy link
Contributor Author

jaguilar commented Dec 12, 2025

Added the ability to manually specify the device using PYBRICKS_VIRTUALHUB_USB_PATH=N/M/O/.... It's pretty bad because btstack does not check the bus number, so ambiguity is possible, but it's something. We could fix btstack to do this correctly fairly easily.

To test:

(pybricks-micropython-py3.12) jaguilar@DESKTOP-2GF79BV:~/projects/jj-pb$ lsusb -t
/:  Bus 001.Port 001: Dev 001, Class=root_hub, Driver=vhci_hcd/8p, 480M
    |__ Port 002: Dev 003, If 0, Class=Communications, Driver=cdc_acm, 12M
    |__ Port 002: Dev 003, If 1, Class=CDC Data, Driver=cdc_acm, 12M
    |__ Port 002: Dev 003, If 2, Class=Vendor Specific Class, Driver=[none], 12M
    |__ Port 003: Dev 041, If 0, Class=Wireless, Driver=[none], 12M
    |__ Port 003: Dev 041, If 1, Class=Wireless, Driver=[none], 12M

So the wireless device is on bus 1 port 3. btstack ignores the bus number, so we'll just be looking at the port:

make -C ./micropython/mpy-cross && make DEBUG=1 BUILD=build-debug virtualhub && PYBRICKS_VIRTUALHUB_USB_PATH=3 bricks/virtualhub/build-debug/firmware.elf ./tests/virtualhub/basics/hello.py 2>&1 | less

The correct device is used.

make -C ./micropython/mpy-cross && make DEBUG=1 BUILD=build-debug virtualhub && PYBRICKS_VIRTUALHUB_USB_PATH=2 bricks/virtualhub/build-debug/firmware.elf ./tests/virtualhub/basics/hello.py 2>&1 | less

Initialization fails and the program hangs forever.

I suppose we could have a folder bluetooth_firmware in our repository . . .

This is now implemented.

Re: CI: Normally installing is preferred but in this case we don't need either. ...

Need some more practical guidance on this. Right now virtualhub does not build on CI due to the libusb dependency. Practically speaking, do I need to create a new target that has the dep and not have that target on CI? Or make the dep on libusb weak instead?

@laurensvalk
Copy link
Member

Thanks for the updates!

I think we already have a candidate bug where this PR would be a big help: pybricks/support#2497

Need some more practical guidance on this. Right now virtualhub does not build on CI due to the libusb dependency.

I'll have a look at this when I get to around to the review. My thinking was that we would not be building the Bluetooth driver at all on the CI build, just like the bluetooth_simulation driver currently doesn't modify stdin settings since it messes up the CI output.

I suppose it would help if I switched the current virtualhub to use the virtual usb driver by default, so we only enable the bluetooth simulation driver for local use. I'll try and do that today.

@dlech
Copy link
Member

dlech commented Dec 12, 2025

just like the bluetooth_simulation driver currently doesn't modify stdin settings since it messes up the CI output.

FYI, there is a isatty() function for this sort of thing to check at runtime rather than making it a compile-time option.

@jaguilar
Copy link
Contributor Author

FYI, BTStack merged my pull request, which means at least in the development branch there is also the possibility to specify the USB bus to further disambiguate which device you're referring to.

bluekitchen/btstack@dd368e2

I'm not sure if we would want to cherry pick that into our view of btstack (and I don't know how to do that), or if we would just wait until it makes it to mainline.

@laurensvalk
Copy link
Member

I suppose it would help if I switched the current virtualhub to use the virtual usb driver by default, so we only enable the bluetooth simulation driver for local use. I'll try and do that today.

Updated the master branch to use the USB mock on the CI so we have flexibility for Bluetooth stuff locally. Sorry for the delay.

This will still print Pybricks stdout to native stdout in addition to whatever we will do with real Bluetooth, so this is still useful for non-bluetooth developments.

@laurensvalk
Copy link
Member

Two TP Link UB500 Bluetooth dongles on the way!

@dlech
Copy link
Member

dlech commented Dec 19, 2025

I still think it would be better/simpler if we just stick with HCI UART for the virtual hub.

In Bleak we recently got a contribution to do automated testing using the Bumble Bluetooth stack. This is a Bluetooth stack written entirely in Python. It can create a pty to simulate an HCI UART controller that can be controlled entirely from Python. We could use this to write CI tests to test all of our Bluetooth code (well, at least BTStack).

And for manual testing, it can easily be switched out for read hardware.

@jaguilar
Copy link
Contributor Author

@dlech I’m not opposed to also supporting bumble. I think it would just involve adjusting the transport to connect to a pty instead of using libusb. I don’t think that anything in this pr precludes doing that, and I suspect it may be useful to have both options (virtual hub supports physical hardware and simulated controller).

@laurensvalk
Copy link
Member

When initially proposing this in pybricks/support#2461, I was thinking to use it more as a development tool, for use with real hardware, given the lack of a real debugger on the LEGO hubs:

  • Development of new Bluetooth classic components (RFCOMM, gamepads, i.e. @jaguilar's contributions)
  • Development of MicroPython APIs for [Feature] Reintroduce pybricks.messaging module support#2274
  • Future developments on BLE (e.g. more than one peripheral, scanning/advertising protocol)
  • Debugging Pybricks Code + pybricks-micropython entirely on one machine.

I don't really mind exactly how we implement it. Using it with a dedicated USB dongle as proposed here is fine by me.

If we can do additional automated CI testing with a variation of this then that's a nice bonus but not a hard requirement for me personally.

uint16_t manufacturer;
uint16_t lmp_pal_subversion;

} pbdrv_bluetooth_btstack_device_discriminator;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} pbdrv_bluetooth_btstack_device_discriminator;
} pbdrv_bluetooth_btstack_device_discriminator_t;

"""

import argparse
import os
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os appears unused.

from pathlib import Path

# Destination directory in user's cache directory
DEST_DIR = Path.cwd() / ".bt_firmware"
Copy link
Member

@laurensvalk laurensvalk Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this assumes we must run from the workspace base directory? Should we raise an error if that isn't the case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is bluetooth firmware, should we perhaps put it in lib/pbio/drv/bluetooth/firmware/libusb?

@laurensvalk
Copy link
Member

laurensvalk commented Dec 22, 2025

Hmm, I thought I'd try to do the review in the fancy editor. Turns out all feedback was lost 😞 .

// Parses a USB path in PYBRICKS_VIRTUALHUB_USB_PATH and sets
// hci_transport_usb_set_path using the result. The expected format is
// 8 bit unsigned integers separated by slashes. If an incorrect format
// is passed, no path is set.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to add a few words here as to what it will do if no path is provided. It looks like ultimately it will try to find the first device that matches is_known_bt_device. We could add a printf in this module to display what we'll choose.

++usb_path;
--len;
} else {
pbdrv_uart_debug_printf("Invalid USB path format (should be <port>[/<port>...])\n");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this do on POSIX? We can use regular printf (or, introduce pbio/debug, mapping to printf on POSIX -- in the long run).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a patch that converts this to fprintf(stderr, ...) in my local tree. For now it does nothing in the commits I've sent out so far.

do_poll_handler = false;
btstack_run_loop_base_poll_data_sources();

#if PBDRV_CONFIG_BLUETOOTH_BTSTACK_POSIX
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could have a pbdrv_bluetooth_btstack_poll_hook() that does the following. On the other implementations, this can be a no-op.

@laurensvalk
Copy link
Member

laurensvalk commented Dec 22, 2025

I think I'm almost getting this running (I see HCI traffic) but it faults at:

static bool le_device_db_tlv_fetch(int index, le_device_db_entry_t * entry){
    btstack_assert(le_device_db_tlv_btstack_tlv_impl != NULL);  <---

This would be set by le_device_db_tlv_configure under BTSTACK_EVENT_STATE in their example packet handler, but I don't think ours includes this.

You were able to run it without, so maybe I'm missing a step somewhere?

Update 1: Looks like I can get around it by disabling theENABLE_LE_PRIVACY_ADDRESS_RESOLUTION config flag. Let's see if we can get something up and running. Exciting!

Update 2: Can't seem to get Bluetooth operations going just yet. What did you test it with so far?

@laurensvalk
Copy link
Member

With a bit more hacking, I think it is sending the right HCI commands to e.g. start advertising but I don't seem to get any actual radio output. 🤔

>>> hub.ble.broadcast(123)

HCI out packet type: 01, len: 35
hci.c.1577: Num LE Peripheral roles: 0 of 2
HCI in packet type: 04, len: 2
BT Event: 6e
HCI in packet type: 04, len: 6
HCI Command Complete: Opcode 2008
BT Event: 0e
HCI out packet type: 01, len: 18

HCI in packet type: 04, len: 2
BT Event: 6e
HCI in packet type: 04, len: 6
HCI Command Complete: Opcode 2006
BT Event: 0e
HCI out packet type: 01, len: 4
HCI in packet type: 04, len: 2
BT Event: 6e
HCI in packet type: 04, len: 6
HCI Command Complete: Opcode 200a
BT Event: 0e

>>> hub.ble.broadcast(None)

HCI out packet type: 01, len: 4
HCI in packet type: 04, len: 2
BT Event: 6e

>>> HCI in packet type: 04, len: 6
HCI Command Complete: Opcode 200a
BT Event: 0e

@laurensvalk
Copy link
Member

laurensvalk commented Dec 22, 2025

OK, it does appear that the very first attempt after reboot power cycling the dongle does work, so that is a promising start.

I think I'll have to go back now and see which patches I tried were really needed, if any, and build from there.

@laurensvalk
Copy link
Member

That said, just this testing has uncovered two unrelated bugs because we can debug it so easily now. Thanks @jaguilar!

@laurensvalk
Copy link
Member

laurensvalk commented Dec 23, 2025

Hmm, this doesn't seem to actually uploads any firmware files?

  • The HCI manufacturer info is read off by one, so it doesn't find the manufacturer.
  • When fixing that, it still doesn't upload any files since the product ID is not read or set.
  • When you do set the product ID, it still doesn't find it because the UB500 (0x0604) is not included in in fw_patch_table.
  • Even if it was in the table, it's missing the .bin suffix to open the file.

So it seems somewhat implausible that this was uploading firmware to the UB500, but maybe I'm missing something?

Instead, I was able to get something going using the stock firmware loaded by the OS. I wonder if you have been using the stock firmware too?

The rtl8761bu_fw does appear in the patch table, but under a different product ID. Manually added it and nothing broke so far.

@laurensvalk
Copy link
Member

laurensvalk commented Dec 23, 2025

I think we could be a bit pragmatic here.

This addition is very useful, but it will only be used by a handful of developers. And the UB500 dongle recommended above is only $10. If someone comes along and really wants to use another dongle, we can just add support for it then. In its current form, it seems hard to guarantee that anything untested would otherwise work.

If we take this approach, we can use the PID and VID build flags that BTstack already has so it never accidentally picks up anything else. We can specify the port number in case you want to use two for certain test scenarios. (EDIT: Can't pick port in this case.) There is also just one firmware and config file to use. I wonder if we even need to upload a firmware at all? We could include it in pbio/drv/bluetooth_btstack/firmware much like the other binaries we have there.

If the device is not detected, the virtualhub will work normally without Bluetooth support to just keep things simple. This also lets you dedicate the UB500 for virtual hub development (setting up udev to unbind it), ensuring that other BLE dongles keep working normally for everyday purposes.

Does that make sense?

@laurensvalk
Copy link
Member

Whichever way we go, I've made made some generalizations in #442 which should help here. Once that is merged, we can rebase this one to make use of the changes.

Anything else that touches existing platforms can be added to #442 for independent testing. That's kind of important because the following diff here breaks EV3 and SPIKE 😄.

-uint16_t lmp_pal_subversion = pbio_get_uint16_le(&rp[7]);
+device_info.lmp_pal_subversion = pbio_get_uint16_le(&rp[6]);

@jaguilar
Copy link
Contributor Author

jaguilar commented Dec 23, 2025

Thanks for taking the time to look at this. I had a chance to go back and check my original stack of commits today. Here's what I'm seeing on my end.

  1. At least on my machine, the commit as I originally formulated them does get to the state of initializing the bluetooth driver (i.e. I'm still getting the same printout as mentioned earlier). Note that I did not try to use the BT device, so maybe the asserts you're seeing are from later on in the process?

  2. You're right that we're not getting a firmware upload with the TP500. I had read that the correct firmware/config were rtl8761bu_fw.bin/rtl8761bu_config.bin, saw that those were in the linux-firmware tree, and saw that things were initializing correctly, and assumed that things lined up.

    It looks like perhaps the btstack chipset needs to be updated to contain this firmware, or else we need to manually add it to the list. Another wrinkle is that according to this, the firmware in the linux-firmware tree is actually not the correct one, although the correct one can be acquired. But if things are working as they are, I'm inclined to agree w/ you that we should call it good enough.

  3. With regards to the erroneous diff you mentioned -- we shouldn't be using the pbio functions for this at all. BTStack has functions to get each value out of any given type of packet, and we should be using those. I'll send a patch. Sadly I'm not turning up any such helpers for the command complete stuff. :(

  4. Regarding your suggestion to make this less generic and specify an acceptable mfg/product id, seems totally reasonable to me!

@laurensvalk
Copy link
Member

Ah, so that makes sense. The good news is that we are able to get firmware going on it. This actually solves my problem of having to otherwise power cycle it, but maybe there's a way to get it in a good state even without uploading any firmware. We can get past all of the missing stuff by just doing:

                btstack_chipset_realtek_set_firmware_file_path(".bt_firmware/rtl8761bu_fw.bin");
                btstack_chipset_realtek_set_config_file_path(".bt_firmware/rtl8761bu_config.bin");

This makes everything reasonably simple, especially if we did choose to go ahead with just this one device. Unfortunately enabling the PID/VID build flags make it ignore the port selection. We can just make it fail later if it isn't there.

But I'm leaning towards doing a quick device check early on in our own new init hook before BTstack even starts. If all checks pass, then we know BTstack can take it from there. If not, we can gracefully say that the device is not there and launch the Virtual Hub normally without trying to somehow escape from the runloop later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants