From 5bb845e03efefbb473e267bbad7a789c5cd83c43 Mon Sep 17 00:00:00 2001 From: James Aguilar Date: Thu, 20 Nov 2025 08:38:47 -0700 Subject: [PATCH] pybricks/bluetooth: Add bt classic inquiry scans. Any Pybricks device running btstack can now issue Bluetooth classic inquiry scans to find and report discoverable devices. This API is experimental and we can change it later if we don't like it. But it's nice because it can be used to concretely demonstrate that the EV3's BTStack layer is doing something! This also should give some indication of the structure by which we'll add further classic functionality. If it's preferred to have it in the same file as the LE code, we can, but this structure is scarcely more expensive and a little more neatly organized. --- bricks/_common/qstrdefs.h | 4 + bricks/_common/sources.mk | 2 + bricks/ev3/mpconfigport.h | 1 + lib/pbio/drv/bluetooth/bluetooth_btstack.c | 5 + .../drv/bluetooth/bluetooth_btstack_classic.c | 101 +++++++++ .../drv/bluetooth/bluetooth_btstack_classic.h | 13 ++ lib/pbio/include/pbdrv/bluetooth.h | 43 +++- lib/pbio/platform/ev3/pbdrvconfig.h | 1 + pybricks/experimental/pb_module_btc.c | 191 ++++++++++++++++++ .../experimental/pb_module_experimental.c | 7 + 10 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 lib/pbio/drv/bluetooth/bluetooth_btstack_classic.c create mode 100644 lib/pbio/drv/bluetooth/bluetooth_btstack_classic.h create mode 100644 pybricks/experimental/pb_module_btc.c diff --git a/bricks/_common/qstrdefs.h b/bricks/_common/qstrdefs.h index a7f3b53e1..dc432b4ac 100644 --- a/bricks/_common/qstrdefs.h +++ b/bricks/_common/qstrdefs.h @@ -15,6 +15,10 @@ Q(pybricks.ev3devices) Q(pybricks.experimental) #endif +#if PYBRICKS_PY_COMMON_BTC +Q(pybricks.experimental.btc) +#endif + #if MICROPY_PY_BUILTINS_FLOAT // backwards compatibility with Pybricks V3.2, maps to pybricks.tools Q(pybricks.geometry) #endif diff --git a/bricks/_common/sources.mk b/bricks/_common/sources.mk index aefbf87fb..e151fbfec 100644 --- a/bricks/_common/sources.mk +++ b/bricks/_common/sources.mk @@ -33,6 +33,7 @@ PYBRICKS_PYBRICKS_SRC_C = $(addprefix pybricks/,\ ev3devices/pb_type_ev3devices_infraredsensor.c \ ev3devices/pb_type_ev3devices_touchsensor.c \ ev3devices/pb_type_ev3devices_ultrasonicsensor.c \ + experimental/pb_module_btc.c \ experimental/pb_module_experimental.c \ hubs/pb_module_hubs.c \ hubs/pb_type_buildhat.c \ @@ -116,6 +117,7 @@ PBIO_SRC_C = $(addprefix lib/pbio/,\ drv/bluetooth/bluetooth.c \ drv/bluetooth/bluetooth_btstack_stm32_hal.c \ drv/bluetooth/bluetooth_btstack.c \ + drv/bluetooth/bluetooth_btstack_classic.c \ drv/bluetooth/bluetooth_btstack_ev3.c \ drv/bluetooth/bluetooth_simulation.c \ drv/bluetooth/bluetooth_stm32_bluenrg.c \ diff --git a/bricks/ev3/mpconfigport.h b/bricks/ev3/mpconfigport.h index 85f9cc1fe..55c423371 100644 --- a/bricks/ev3/mpconfigport.h +++ b/bricks/ev3/mpconfigport.h @@ -16,6 +16,7 @@ // Pybricks modules #define PYBRICKS_PY_COMMON (1) #define PYBRICKS_PY_COMMON_BLE (0) +#define PYBRICKS_PY_COMMON_BTC (1) #define PYBRICKS_PY_COMMON_CHARGER (0) #define PYBRICKS_PY_COMMON_COLOR_LIGHT (1) #define PYBRICKS_PY_COMMON_CONTROL (1) diff --git a/lib/pbio/drv/bluetooth/bluetooth_btstack.c b/lib/pbio/drv/bluetooth/bluetooth_btstack.c index 2fb80de4c..1e7ccbe8d 100644 --- a/lib/pbio/drv/bluetooth/bluetooth_btstack.c +++ b/lib/pbio/drv/bluetooth/bluetooth_btstack.c @@ -23,6 +23,7 @@ #include "bluetooth.h" #include "bluetooth_btstack.h" +#include "bluetooth_btstack_classic.h" #if PBDRV_CONFIG_BLUETOOTH_BTSTACK_LE #include "genhdr/pybricks_service.h" @@ -1181,6 +1182,10 @@ void pbdrv_bluetooth_init_hci(void) { nordic_spp_service_server_init(nordic_spp_packet_handler); #endif // PBDRV_CONFIG_BLUETOOTH_BTSTACK_LE + #if PBDRV_CONFIG_BLUETOOTH_CLASSIC + pbdrv_bluetooth_classic_init(); + #endif + bluetooth_thread_err = PBIO_ERROR_AGAIN; bluetooth_thread_state = 0; pbio_os_process_start(&pbdrv_bluetooth_hci_process, pbdrv_bluetooth_hci_process_thread, NULL); diff --git a/lib/pbio/drv/bluetooth/bluetooth_btstack_classic.c b/lib/pbio/drv/bluetooth/bluetooth_btstack_classic.c new file mode 100644 index 000000000..fae7d0dff --- /dev/null +++ b/lib/pbio/drv/bluetooth/bluetooth_btstack_classic.c @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +// This file defines the functions required to implement Pybricks' Bluetooth +// classic functionality using BTStack. + +#include + +#if PBDRV_CONFIG_BLUETOOTH_BTSTACK_CLASSIC + +#include + +#include +#include +#include + +#include + +#include + +static int32_t pending_inquiry_response_count; +static int32_t pending_inquiry_response_limit; +static void *pending_inquiry_result_handler_context; +static pbdrv_bluetooth_inquiry_result_handler_t pending_inquiry_result_handler; + +static void handle_hci_event_packet(uint8_t *packet, uint16_t size); + +void pbdrv_bluetooth_classic_handle_packet(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size) { + switch (packet_type) { + case HCI_EVENT_PACKET: + handle_hci_event_packet(packet, size); + } +} + +static void handle_hci_event_packet(uint8_t *packet, uint16_t size) { + switch (hci_event_packet_get_type(packet)) { + case GAP_EVENT_INQUIRY_RESULT: { + if (!pending_inquiry_result_handler) { + return; + } + pbdrv_bluetooth_inquiry_result_t result; + gap_event_inquiry_result_get_bd_addr(packet, result.bdaddr); + if (gap_event_inquiry_result_get_rssi_available(packet)) { + result.rssi = gap_event_inquiry_result_get_rssi(packet); + } + if (gap_event_inquiry_result_get_name_available(packet)) { + const uint8_t *name = gap_event_inquiry_result_get_name(packet); + const size_t name_len = gap_event_inquiry_result_get_name_len(packet); + snprintf(result.name, sizeof(result.name), "%.*s", (int)name_len, name); + } + result.class_of_device = gap_event_inquiry_result_get_class_of_device(packet); + pending_inquiry_result_handler(pending_inquiry_result_handler_context, &result); + if (pending_inquiry_response_limit > 0) { + pending_inquiry_response_count++; + if (pending_inquiry_response_count >= pending_inquiry_response_limit) { + gap_inquiry_stop(); + } + } + break; + } + case GAP_EVENT_INQUIRY_COMPLETE: { + if (pending_inquiry_result_handler) { + pending_inquiry_result_handler = NULL; + pending_inquiry_result_handler_context = NULL; + pbio_os_request_poll(); + } + break; + } + default: + break; + } +} + +void pbdrv_bluetooth_classic_init() { + static btstack_packet_callback_registration_t hci_event_handler_registration; + hci_event_handler_registration.callback = pbdrv_bluetooth_classic_handle_packet; + hci_add_event_handler(&hci_event_handler_registration); +} + +pbio_error_t pbdrv_bluetooth_inquiry_scan( + pbio_os_state_t *state, + int32_t max_responses, + int32_t timeout, + void *context, + pbdrv_bluetooth_inquiry_result_handler_t result_handler) { + PBIO_OS_ASYNC_BEGIN(state); + if (pending_inquiry_result_handler) { + return PBIO_ERROR_BUSY; + } + PBIO_OS_AWAIT_UNTIL(state, hci_get_state() == HCI_STATE_WORKING); + pending_inquiry_response_count = 0; + pending_inquiry_response_limit = max_responses; + pending_inquiry_result_handler = result_handler; + pending_inquiry_result_handler_context = context; + gap_inquiry_start(timeout); + + PBIO_OS_AWAIT_UNTIL(state, !pending_inquiry_result_handler); + PBIO_OS_ASYNC_END(PBIO_SUCCESS); +} + +#endif // PBDRV_CONFIG_BLUETOOTH_BTSTACK_CLASSIC diff --git a/lib/pbio/drv/bluetooth/bluetooth_btstack_classic.h b/lib/pbio/drv/bluetooth/bluetooth_btstack_classic.h new file mode 100644 index 000000000..eee0e70a8 --- /dev/null +++ b/lib/pbio/drv/bluetooth/bluetooth_btstack_classic.h @@ -0,0 +1,13 @@ +#ifndef PBDRV_BLUETOOTH_BLUETOOTH_BTSTACK_CLASSIC_H +#define PBDRV_BLUETOOTH_BLUETOOTH_BTSTACK_CLASSIC_H +#include + +#if PBDRV_CONFIG_BLUETOOTH_CLASSIC + +#include + +void pbdrv_bluetooth_classic_init(); + +#endif // PBDRV_CONFIG_BLUETOOTH_CLASSIC + +#endif // PBDRV_BLUETOOTH_BLUETOOTH_BTSTACK_CLASSIC_H diff --git a/lib/pbio/include/pbdrv/bluetooth.h b/lib/pbio/include/pbdrv/bluetooth.h index 67f50abee..88f95e737 100644 --- a/lib/pbio/include/pbdrv/bluetooth.h +++ b/lib/pbio/include/pbdrv/bluetooth.h @@ -607,9 +607,50 @@ static inline pbio_error_t pbdrv_bluetooth_close_user_tasks(pbio_os_state_t *sta return PBIO_SUCCESS; } - #endif // PBDRV_CONFIG_BLUETOOTH +#if PBDRV_CONFIG_BLUETOOTH_CLASSIC + +// A single result from an inquiry scan. +typedef struct { + uint8_t bdaddr[6]; + int8_t rssi; + char name[249]; + uint32_t class_of_device; +} pbdrv_bluetooth_inquiry_result_t; + +// Callback for handling inquiry results. +typedef void (*pbdrv_bluetooth_inquiry_result_handler_t)(void *context, const pbdrv_bluetooth_inquiry_result_t *result); + +/** + * Runs a bluetooth inquiry scan. Only one such scan can be active at a time. + * + * @param [in] state Protothread state. + * @param [in] max_responses Maximum number of responses to report. Use -1 for unlimited. + * @param [in] timeout Timeout in units of 1.28 seconds. Values less than one will be coerced to one. + * @param [in] context Context pointer to be passed to the result handler. + * @param [in] result_handler Callback that will be called for each inquiry result. + */ +pbio_error_t pbdrv_bluetooth_inquiry_scan( + pbio_os_state_t *state, + int32_t max_responses, + int32_t timeout, + void *context, + pbdrv_bluetooth_inquiry_result_handler_t result_handler); + +#else // PBDRV_CONFIG_BLUETOOTH_CLASSIC + +static inline pbio_error_t pbdrv_bluetooth_inquiry_scan( + pbio_os_state_t *state, + int32_t max_responses, + int32_t timeout, + void *context, + void *result_handler) { + return PBIO_ERROR_NOT_SUPPORTED; +} + +#endif // PBDRV_CONFIG_BLUETOOTH_CLASSIC + #endif // _PBDRV_BLUETOOTH_H_ /** @} */ diff --git a/lib/pbio/platform/ev3/pbdrvconfig.h b/lib/pbio/platform/ev3/pbdrvconfig.h index 0518d173f..256f8fddf 100644 --- a/lib/pbio/platform/ev3/pbdrvconfig.h +++ b/lib/pbio/platform/ev3/pbdrvconfig.h @@ -43,6 +43,7 @@ #define PBDRV_CONFIG_I2C_EV3 (1) #define PBDRV_CONFIG_BLUETOOTH (1) +#define PBDRV_CONFIG_BLUETOOTH_CLASSIC (1) #define PBDRV_CONFIG_BLUETOOTH_MAX_MTU_SIZE 515 #define PBDRV_CONFIG_BLUETOOTH_BTSTACK (1) #define PBDRV_CONFIG_BLUETOOTH_BTSTACK_CLASSIC (1) diff --git a/pybricks/experimental/pb_module_btc.c b/pybricks/experimental/pb_module_btc.c new file mode 100644 index 000000000..7ae724ad5 --- /dev/null +++ b/pybricks/experimental/pb_module_btc.c @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +#include "py/mpconfig.h" + +#if PYBRICKS_PY_COMMON_BTC + +#include +#include +#include + +#include + +#include +#include + +#include "py/obj.h" +#include "py/misc.h" +#include "py/mphal.h" +#include "py/runtime.h" + +#include +#include +#include +#include +#include +#include + +// Register allocated global variables as GC roots since they don't otherwise +// have any parent object to reference them. +MP_REGISTER_ROOT_POINTER(void *scan_pending_results); +MP_REGISTER_ROOT_POINTER(void *scan_awaitable); + +#define GET_SCAN_PENDING_RESULTS() ((pbdrv_bluetooth_inquiry_result_t *)MP_STATE_VM(scan_pending_results)) +#define SET_SCAN_PENDING_RESULTS(ptr) (MP_STATE_VM(scan_pending_results) = (struct pbdrv_bluetooth_inquiry_result_t *)(ptr)) +#define GET_SCAN_AWAITABLE() ((pb_type_async_t *)MP_STATE_VM(scan_awaitable)) +#define SET_SCAN_AWAITABLE(ptr) (MP_STATE_VM(scan_awaitable) = (pb_type_async_t *)(ptr)) + +static size_t scan_results_max_size = 0; +static size_t scan_results_current_index = 0; +static int32_t scan_timeout = 10; + +/** + * Handles inquiry result from the bluetooth driver. + * + * @param [in] context The result list (unused in this phase). + * @param [in] result The inquiry result from the Bluetooth Classic scan. + */ +static void handle_inquiry_result(void *context, const pbdrv_bluetooth_inquiry_result_t *result) { + if (result == NULL) { + return; + } + + // If we haven't reached the maximum number of results and allocation is valid, store this one + if (GET_SCAN_PENDING_RESULTS() != NULL && scan_results_current_index < scan_results_max_size) { + memcpy(&GET_SCAN_PENDING_RESULTS()[scan_results_current_index], result, sizeof(pbdrv_bluetooth_inquiry_result_t)); + scan_results_current_index++; + } +} + +static pbio_error_t pb_type_btc_scan_iterate(pbio_os_state_t *state, mp_obj_t parent_obj) { + return pbdrv_bluetooth_inquiry_scan(state, (int32_t)scan_results_max_size, scan_timeout, NULL, handle_inquiry_result); +} + +/** + * Creates the return value for the scan operation. + * + * @param [in] parent_obj The result list that will be populated. + * @return A Python list containing dictionaries with scan results. + */ +static mp_obj_t pb_type_btc_scan_return_map(mp_obj_t result_list) { + // Convert each stored result to a Python dictionary and add to the list + if (GET_SCAN_PENDING_RESULTS() != NULL) { + for (size_t i = 0; i < scan_results_current_index; i++) { + const pbdrv_bluetooth_inquiry_result_t *result = &GET_SCAN_PENDING_RESULTS()[i]; + mp_obj_t result_dict = mp_obj_new_dict(0); + + // Format Bluetooth address + char bdaddr_str[18]; + snprintf(bdaddr_str, sizeof(bdaddr_str), "%02x:%02x:%02x:%02x:%02x:%02x", + result->bdaddr[5], result->bdaddr[4], result->bdaddr[3], + result->bdaddr[2], result->bdaddr[1], result->bdaddr[0]); + + // Add fields to dictionary + mp_obj_dict_store(result_dict, + MP_ROM_QSTR(MP_QSTR_address), + mp_obj_new_str(bdaddr_str, strlen(bdaddr_str))); + mp_obj_dict_store(result_dict, + MP_ROM_QSTR(MP_QSTR_name), + mp_obj_new_bytes((const byte *)result->name, strlen(result->name))); + mp_obj_dict_store(result_dict, + MP_ROM_QSTR(MP_QSTR_rssi), + mp_obj_new_int(result->rssi)); + mp_obj_dict_store(result_dict, + MP_ROM_QSTR(MP_QSTR_class_of_device), + mp_obj_new_int(result->class_of_device)); + mp_obj_list_append(result_list, result_dict); + } + + // Free the allocated memory + m_del(pbdrv_bluetooth_inquiry_result_t, GET_SCAN_PENDING_RESULTS(), scan_results_max_size); + SET_SCAN_PENDING_RESULTS(NULL); + } + + return result_list; +} + +/** + * Starts a Bluetooth Classic inquiry scan. + * + * @param [in] n_args Number of positional arguments. + * @param [in] pos_args Positional arguments (none expected). + * @param [in] kw_args Keyword arguments (max_devices and timeout are optional). + * max_devices (default: 20) - Maximum number of devices to find + * timeout (default: 10) - Scan timeout in seconds + * @return An awaitable that will return a list of scan results. + */ +static mp_obj_t pb_type_btc_scan(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + // Define the allowed keyword arguments + static const mp_arg_t allowed_args[] = { + { MP_QSTR_max_devices, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 20} }, + { MP_QSTR_timeout, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 10} }, + }; + + // Parse the keyword arguments + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + // Get max_devices parameter + int32_t max_devices = args[0].u_int; + if (max_devices < 1) { + mp_raise_ValueError(MP_ERROR_TEXT("max_devices must be at least 1")); + return MP_OBJ_NULL; + } + + // Get timeout parameter + scan_timeout = args[1].u_int; + if (scan_timeout < 1) { + mp_raise_ValueError(MP_ERROR_TEXT("timeout must be at least 1")); + return MP_OBJ_NULL; + } + + // Free any previously allocated memory + if (GET_SCAN_PENDING_RESULTS() != NULL) { + m_del(pbdrv_bluetooth_inquiry_result_t, GET_SCAN_PENDING_RESULTS(), scan_results_max_size); + SET_SCAN_PENDING_RESULTS(NULL); + } + + // Allocate memory for the scan results + SET_SCAN_PENDING_RESULTS(m_new(pbdrv_bluetooth_inquiry_result_t, max_devices)); + if (GET_SCAN_PENDING_RESULTS() == NULL) { + mp_raise_OSError(MP_ENOMEM); + } + // Initialize variables for this scan + scan_results_max_size = (size_t)max_devices; + scan_results_current_index = 0; + + // Create the result list that will be populated during scanning + mp_obj_t result_list = mp_obj_new_list(0, NULL); + + pb_type_async_t config = { + .iter_once = pb_type_btc_scan_iterate, + .parent_obj = result_list, // Pass the result list as parent_obj + .close = NULL, // No close function needed for module + .return_map = pb_type_btc_scan_return_map, + }; + + pb_type_async_t *scan_awaitable_ptr = GET_SCAN_AWAITABLE(); + mp_obj_t result = pb_type_async_wait_or_await(&config, &scan_awaitable_ptr, true); + SET_SCAN_AWAITABLE(scan_awaitable_ptr); + + return result; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(pb_type_btc_scan_obj, 0, pb_type_btc_scan); + +static const mp_rom_map_elem_t common_BTC_globals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_btc) }, + { MP_ROM_QSTR(MP_QSTR_scan), MP_ROM_PTR(&pb_type_btc_scan_obj) }, +}; +static MP_DEFINE_CONST_DICT(common_BTC_globals_dict, common_BTC_globals_dict_table); + +const mp_obj_module_t pb_module_btc = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&common_BTC_globals_dict, +}; + +#if !MICROPY_MODULE_BUILTIN_SUBPACKAGES +MP_REGISTER_MODULE(MP_QSTR_pybricks_dot_experimental_dot_btc, pb_module_btc); +#endif + +#endif // PYBRICKS_PY_COMMON_BTC diff --git a/pybricks/experimental/pb_module_experimental.c b/pybricks/experimental/pb_module_experimental.c index 6f252a925..81f318486 100644 --- a/pybricks/experimental/pb_module_experimental.c +++ b/pybricks/experimental/pb_module_experimental.c @@ -20,6 +20,10 @@ #include +#if PYBRICKS_PY_COMMON_BTC +extern const mp_obj_module_t pb_module_btc; +#endif + // pybricks.experimental.hello_world static mp_obj_t experimental_hello_world(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { PB_PARSE_ARGS_FUNCTION(n_args, pos_args, kw_args, @@ -61,6 +65,9 @@ static MP_DEFINE_CONST_FUN_OBJ_KW(experimental_hello_world_obj, 0, experimental_ static const mp_rom_map_elem_t experimental_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_experimental) }, { MP_ROM_QSTR(MP_QSTR_hello_world), MP_ROM_PTR(&experimental_hello_world_obj) }, + #if PYBRICKS_PY_COMMON_BTC && MICROPY_MODULE_BUILTIN_SUBPACKAGES + { MP_ROM_QSTR(MP_QSTR_btc), MP_ROM_PTR(&pb_module_btc) }, + #endif }; static MP_DEFINE_CONST_DICT(pb_module_experimental_globals, experimental_globals_table);