From 1051648ffb1a1827404a9c8008d4aa486d4c1208 Mon Sep 17 00:00:00 2001 From: Gabriel White-Vega Date: Tue, 10 Oct 2023 11:46:59 -0400 Subject: [PATCH 1/3] Add support for extended advertising via Rust-only API * Extended functionality is gated on an "unstable" feature * Designed for very simple use and minimal interferance with existing legacy implementation * Intended to be temporary, until bumble can integrate extended advertising into its core functionality * Dropped `HciCommandWrapper` in favor of using bumble's `HCI_Command.from_bytes` for converting from PDL into bumble implementation * Refactored Address and Device constructors to better match what the python constructors expect --- bumble/controller.py | 16 +- rust/Cargo.toml | 10 + rust/examples/battery_client.rs | 9 +- rust/examples/broadcast.rs | 26 +- rust/examples/scanner.rs | 12 +- rust/pytests/wrapper.rs | 77 ------ rust/pytests/wrapper/drivers.rs | 22 ++ rust/pytests/wrapper/hci.rs | 86 +++++++ rust/pytests/wrapper/mod.rs | 17 ++ rust/pytests/wrapper/transport.rs | 31 +++ rust/src/internal/hci/mod.rs | 12 +- rust/src/internal/hci/tests.rs | 17 +- rust/src/wrapper/{device.rs => device/mod.rs} | 224 ++++++++++++++++-- rust/src/wrapper/device/tests.rs | 23 ++ rust/src/wrapper/hci.rs | 84 ++++--- rust/src/wrapper/mod.rs | 9 + 16 files changed, 511 insertions(+), 164 deletions(-) delete mode 100644 rust/pytests/wrapper.rs create mode 100644 rust/pytests/wrapper/drivers.rs create mode 100644 rust/pytests/wrapper/hci.rs create mode 100644 rust/pytests/wrapper/mod.rs create mode 100644 rust/pytests/wrapper/transport.rs rename rust/src/wrapper/{device.rs => device/mod.rs} (64%) create mode 100644 rust/src/wrapper/device/tests.rs diff --git a/bumble/controller.py b/bumble/controller.py index 9b2960a3..baa07469 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -1000,12 +1000,16 @@ def on_hci_le_set_scan_parameters_command(self, command): ''' See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command ''' - self.le_scan_type = command.le_scan_type - self.le_scan_interval = command.le_scan_interval - self.le_scan_window = command.le_scan_window - self.le_scan_own_address_type = command.own_address_type - self.le_scanning_filter_policy = command.scanning_filter_policy - return bytes([HCI_SUCCESS]) + ret = HCI_SUCCESS + if not self.le_scan_enable: + self.le_scan_type = command.le_scan_type + self.le_scan_interval = command.le_scan_interval + self.le_scan_window = command.le_scan_window + self.le_scan_own_address_type = command.own_address_type + self.le_scanning_filter_policy = command.scanning_filter_policy + else: + ret = HCI_COMMAND_DISALLOWED_ERROR + return bytes([ret]) def on_hci_le_set_scan_enable_command(self, command): ''' diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 35a0f4c1..a3c63ba9 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -74,6 +74,11 @@ name = "bumble" path = "src/main.rs" required-features = ["bumble-tools"] +[[example]] +name = "broadcast" +path = "examples/broadcast.rs" +required-features = ["unstable_extended_adv"] + # test entry point that uses pyo3_asyncio's test harness [[test]] name = "pytests" @@ -86,4 +91,9 @@ pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"] dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"] # separate feature for CLI so that dependencies don't spend time building these bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"] + +# all the unstable features +unstable = ["unstable_extended_adv"] +unstable_extended_adv = [] + default = [] diff --git a/rust/examples/battery_client.rs b/rust/examples/battery_client.rs index 007ccb66..613d9e8b 100644 --- a/rust/examples/battery_client.rs +++ b/rust/examples/battery_client.rs @@ -33,6 +33,7 @@ use bumble::wrapper::{ device::{Device, Peer}, + hci::{packets::AddressType, Address}, profile::BatteryServiceProxy, transport::Transport, PyObjectExt, @@ -52,12 +53,8 @@ async fn main() -> PyResult<()> { let transport = Transport::open(cli.transport).await?; - let device = Device::with_hci( - "Bumble", - "F0:F1:F2:F3:F4:F5", - transport.source()?, - transport.sink()?, - )?; + let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?; + let device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?; device.power_on().await?; diff --git a/rust/examples/broadcast.rs b/rust/examples/broadcast.rs index f87b6445..affe21e4 100644 --- a/rust/examples/broadcast.rs +++ b/rust/examples/broadcast.rs @@ -63,17 +63,28 @@ async fn main() -> PyResult<()> { ) .map_err(|e| anyhow!(e))?; - device.set_advertising_data(adv_data)?; device.power_on().await?; - println!("Advertising..."); - device.start_advertising(true).await?; + if cli.extended { + println!("Starting extended advertisement..."); + device.start_advertising_extended(adv_data).await?; + } else { + device.set_advertising_data(adv_data)?; + + println!("Starting legacy advertisement..."); + device.start_advertising(true).await?; + } // wait until user kills the process tokio::signal::ctrl_c().await?; - println!("Stopping..."); - device.stop_advertising().await?; + if cli.extended { + println!("Stopping extended advertisement..."); + device.stop_advertising_extended().await?; + } else { + println!("Stopping legacy advertisement..."); + device.stop_advertising().await?; + } Ok(()) } @@ -86,12 +97,17 @@ struct Cli { /// See, for instance, `examples/device1.json` in the Python project. #[arg(long)] device_config: path::PathBuf, + /// Bumble transport spec. /// /// #[arg(long)] transport: String, + /// Whether to perform an extended (BT 5.0) advertisement + #[arg(long)] + extended: bool, + /// Log HCI commands #[arg(long)] log_hci: bool, diff --git a/rust/examples/scanner.rs b/rust/examples/scanner.rs index ec931b5c..21292d62 100644 --- a/rust/examples/scanner.rs +++ b/rust/examples/scanner.rs @@ -20,7 +20,9 @@ use bumble::{ adv::CommonDataType, wrapper::{ - core::AdvertisementDataUnit, device::Device, hci::packets::AddressType, + core::AdvertisementDataUnit, + device::Device, + hci::{packets::AddressType, Address}, transport::Transport, }, }; @@ -44,12 +46,8 @@ async fn main() -> PyResult<()> { let transport = Transport::open(cli.transport).await?; - let mut device = Device::with_hci( - "Bumble", - "F0:F1:F2:F3:F4:F5", - transport.source()?, - transport.sink()?, - )?; + let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?; + let mut device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?; // in practice, devices can send multiple advertisements from the same address, so we keep // track of a timestamp for each set of data diff --git a/rust/pytests/wrapper.rs b/rust/pytests/wrapper.rs deleted file mode 100644 index 9fd65e73..00000000 --- a/rust/pytests/wrapper.rs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2023 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 -// -// http://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. - -use bumble::wrapper::{ - controller::Controller, - device::Device, - drivers::rtk::DriverInfo, - hci::{ - packets::{ - AddressType, ErrorCode, ReadLocalVersionInformationBuilder, - ReadLocalVersionInformationComplete, - }, - Address, Error, - }, - host::Host, - link::Link, - transport::Transport, -}; -use nix::sys::stat::Mode; -use pyo3::{ - exceptions::PyException, - {PyErr, PyResult}, -}; - -#[pyo3_asyncio::tokio::test] -async fn fifo_transport_can_open() -> PyResult<()> { - let dir = tempfile::tempdir().unwrap(); - let mut fifo = dir.path().to_path_buf(); - fifo.push("bumble-transport-fifo"); - nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap(); - - let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?; - - t.close().await?; - - Ok(()) -} - -#[pyo3_asyncio::tokio::test] -async fn realtek_driver_info_all_drivers() -> PyResult<()> { - assert_eq!(12, DriverInfo::all_drivers()?.len()); - Ok(()) -} - -#[pyo3_asyncio::tokio::test] -async fn hci_command_wrapper_has_correct_methods() -> PyResult<()> { - let address = Address::new("F0:F1:F2:F3:F4:F5", &AddressType::RandomDeviceAddress)?; - let link = Link::new_local_link()?; - let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?; - let host = Host::new(controller.clone().into(), controller.into()).await?; - let device = Device::new(None, Some(address), None, Some(host), None)?; - - device.power_on().await?; - - // Send some simple command. A successful response means [HciCommandWrapper] has the minimum - // required interface for the Python code to think its an [HCI_Command] object. - let command = ReadLocalVersionInformationBuilder {}; - let event: ReadLocalVersionInformationComplete = device - .send_command(&command.into(), true) - .await? - .try_into() - .map_err(|e: Error| PyErr::new::(e.to_string()))?; - - assert_eq!(ErrorCode::Success, event.get_status()); - Ok(()) -} diff --git a/rust/pytests/wrapper/drivers.rs b/rust/pytests/wrapper/drivers.rs new file mode 100644 index 00000000..d2517eb1 --- /dev/null +++ b/rust/pytests/wrapper/drivers.rs @@ -0,0 +1,22 @@ +// Copyright 2023 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 +// +// http://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. + +use bumble::wrapper::drivers::rtk::DriverInfo; +use pyo3::PyResult; + +#[pyo3_asyncio::tokio::test] +async fn realtek_driver_info_all_drivers() -> PyResult<()> { + assert_eq!(12, DriverInfo::all_drivers()?.len()); + Ok(()) +} diff --git a/rust/pytests/wrapper/hci.rs b/rust/pytests/wrapper/hci.rs new file mode 100644 index 00000000..c4ce20d0 --- /dev/null +++ b/rust/pytests/wrapper/hci.rs @@ -0,0 +1,86 @@ +// Copyright 2023 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 +// +// http://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. + +use bumble::wrapper::{ + controller::Controller, + device::Device, + hci::{ + packets::{ + AddressType, Enable, ErrorCode, LeScanType, LeScanningFilterPolicy, + LeSetScanEnableBuilder, LeSetScanEnableComplete, LeSetScanParametersBuilder, + LeSetScanParametersComplete, OwnAddressType, + }, + Address, Error, + }, + host::Host, + link::Link, +}; +use pyo3::{ + exceptions::PyException, + {PyErr, PyResult}, +}; + +#[pyo3_asyncio::tokio::test] +async fn test_hci_roundtrip_success_and_failure() -> PyResult<()> { + let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?; + let device = create_local_device(address).await?; + + device.power_on().await?; + + // BLE Spec Core v5.3 + // 7.8.9 LE Set Scan Parameters command + // ... + // The Host shall not issue this command when scanning is enabled in the + // Controller; if it is the Command Disallowed error code shall be used. + // ... + + let command = LeSetScanEnableBuilder { + filter_duplicates: Enable::Disabled, + // will cause failure later + le_scan_enable: Enable::Enabled, + }; + + let event: LeSetScanEnableComplete = device + .send_command(command.into(), false) + .await? + .try_into() + .map_err(|e: Error| PyErr::new::(e.to_string()))?; + + assert_eq!(ErrorCode::Success, event.get_status()); + + let command = LeSetScanParametersBuilder { + le_scan_type: LeScanType::Passive, + le_scan_interval: 0, + le_scan_window: 0, + own_address_type: OwnAddressType::RandomDeviceAddress, + scanning_filter_policy: LeScanningFilterPolicy::AcceptAll, + }; + + let event: LeSetScanParametersComplete = device + .send_command(command.into(), false) + .await? + .try_into() + .map_err(|e: Error| PyErr::new::(e.to_string()))?; + + assert_eq!(ErrorCode::CommandDisallowed, event.get_status()); + + Ok(()) +} + +async fn create_local_device(address: Address) -> PyResult { + let link = Link::new_local_link()?; + let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?; + let host = Host::new(controller.clone().into(), controller.into()).await?; + Device::new(None, Some(address), None, Some(host), None) +} diff --git a/rust/pytests/wrapper/mod.rs b/rust/pytests/wrapper/mod.rs new file mode 100644 index 00000000..3bc9127b --- /dev/null +++ b/rust/pytests/wrapper/mod.rs @@ -0,0 +1,17 @@ +// Copyright 2023 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 +// +// http://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. + +mod drivers; +mod hci; +mod transport; diff --git a/rust/pytests/wrapper/transport.rs b/rust/pytests/wrapper/transport.rs new file mode 100644 index 00000000..333005bd --- /dev/null +++ b/rust/pytests/wrapper/transport.rs @@ -0,0 +1,31 @@ +// Copyright 2023 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 +// +// http://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. + +use bumble::wrapper::transport::Transport; +use nix::sys::stat::Mode; +use pyo3::PyResult; + +#[pyo3_asyncio::tokio::test] +async fn fifo_transport_can_open() -> PyResult<()> { + let dir = tempfile::tempdir().unwrap(); + let mut fifo = dir.path().to_path_buf(); + fifo.push("bumble-transport-fifo"); + nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap(); + + let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?; + + t.close().await?; + + Ok(()) +} diff --git a/rust/src/internal/hci/mod.rs b/rust/src/internal/hci/mod.rs index 232c49fd..7830e318 100644 --- a/rust/src/internal/hci/mod.rs +++ b/rust/src/internal/hci/mod.rs @@ -94,7 +94,7 @@ impl From for PacketTypeParseError { impl WithPacketType for Command { fn to_vec_with_packet_type(self) -> Vec { - prepend_packet_type(PacketType::Command, self.to_vec()) + prepend_packet_type(PacketType::Command, self) } fn parse_with_packet_type(bytes: &[u8]) -> Result { @@ -104,7 +104,7 @@ impl WithPacketType for Command { impl WithPacketType for Acl { fn to_vec_with_packet_type(self) -> Vec { - prepend_packet_type(PacketType::Acl, self.to_vec()) + prepend_packet_type(PacketType::Acl, self) } fn parse_with_packet_type(bytes: &[u8]) -> Result { @@ -114,7 +114,7 @@ impl WithPacketType for Acl { impl WithPacketType for Sco { fn to_vec_with_packet_type(self) -> Vec { - prepend_packet_type(PacketType::Sco, self.to_vec()) + prepend_packet_type(PacketType::Sco, self) } fn parse_with_packet_type(bytes: &[u8]) -> Result { @@ -124,7 +124,7 @@ impl WithPacketType for Sco { impl WithPacketType for Event { fn to_vec_with_packet_type(self) -> Vec { - prepend_packet_type(PacketType::Event, self.to_vec()) + prepend_packet_type(PacketType::Event, self) } fn parse_with_packet_type(bytes: &[u8]) -> Result { @@ -132,7 +132,9 @@ impl WithPacketType for Event { } } -fn prepend_packet_type(packet_type: PacketType, mut packet_bytes: Vec) -> Vec { +fn prepend_packet_type(packet_type: PacketType, packet: T) -> Vec { + // TODO: refactor if `pdl` crate adds API for writing into buffer (github.com/google/pdl/issues/74) + let mut packet_bytes = packet.to_vec(); packet_bytes.insert(0, packet_type.into()); packet_bytes } diff --git a/rust/src/internal/hci/tests.rs b/rust/src/internal/hci/tests.rs index 7962c881..ff9e72b0 100644 --- a/rust/src/internal/hci/tests.rs +++ b/rust/src/internal/hci/tests.rs @@ -22,9 +22,8 @@ use bytes::Bytes; #[test] fn prepends_packet_type() { let packet_type = PacketType::Event; - let packet_bytes = vec![0x00, 0x00, 0x00, 0x00]; - let actual = prepend_packet_type(packet_type, packet_bytes); - assert_eq!(vec![0x04, 0x00, 0x00, 0x00, 0x00], actual); + let actual = prepend_packet_type(packet_type, FakePacket { bytes: vec![0xFF] }); + assert_eq!(vec![0x04, 0xFF], actual); } #[test] @@ -75,11 +74,15 @@ fn test_packet_roundtrip_with_type() { } #[derive(Debug, PartialEq)] -struct FakePacket; +struct FakePacket { + bytes: Vec, +} impl FakePacket { - fn parse(_bytes: &[u8]) -> Result { - Ok(Self) + fn parse(bytes: &[u8]) -> Result { + Ok(Self { + bytes: bytes.to_vec(), + }) } } @@ -89,6 +92,6 @@ impl Packet for FakePacket { } fn to_vec(self) -> Vec { - Vec::new() + self.bytes } } diff --git a/rust/src/wrapper/device.rs b/rust/src/wrapper/device/mod.rs similarity index 64% rename from rust/src/wrapper/device.rs rename to rust/src/wrapper/device/mod.rs index 6bf958a2..e040a89d 100644 --- a/rust/src/wrapper/device.rs +++ b/rust/src/wrapper/device/mod.rs @@ -14,7 +14,17 @@ //! Devices and connections to them -use crate::internal::hci::WithPacketType; +#[cfg(feature = "unstable_extended_adv")] +use crate::wrapper::{ + hci::packets::{ + self, AdvertisingEventProperties, AdvertisingFilterPolicy, Enable, EnabledSet, + FragmentPreference, LeSetAdvertisingSetRandomAddressBuilder, + LeSetExtendedAdvertisingDataBuilder, LeSetExtendedAdvertisingEnableBuilder, + LeSetExtendedAdvertisingParametersBuilder, Operation, OwnAddressType, PeerAddressType, + PrimaryPhyType, SecondaryPhyType, + }, + ConversionError, +}; use crate::{ adv::AdvertisementDataBuilder, wrapper::{ @@ -22,7 +32,7 @@ use crate::{ gatt_client::{ProfileServiceProxy, ServiceProxy}, hci::{ packets::{Command, ErrorCode, Event}, - Address, HciCommandWrapper, + Address, HciCommand, WithPacketType, }, host::Host, l2cap::LeConnectionOrientedChannel, @@ -39,6 +49,9 @@ use pyo3::{ use pyo3_asyncio::tokio::into_future; use std::path; +#[cfg(test)] +mod tests; + /// Represents the various properties of some device pub struct DeviceConfiguration(PyObject); @@ -69,9 +82,19 @@ impl ToPyObject for DeviceConfiguration { } } +/// Used for tracking what advertising state a device might be in +#[derive(PartialEq)] +enum AdvertisingStatus { + AdvertisingLegacy, + AdvertisingExtended, + NotAdvertising, +} + /// A device that can send/receive HCI frames. -#[derive(Clone)] -pub struct Device(PyObject); +pub struct Device { + obj: PyObject, + advertising_status: AdvertisingStatus, +} impl Device { /// Creates a Device. When optional arguments are not specified, the Python object specifies the @@ -94,7 +117,10 @@ impl Device { PyModule::import(py, intern!(py, "bumble.device"))? .getattr(intern!(py, "Device"))? .call((), Some(kwargs)) - .map(|any| Self(any.into())) + .map(|any| Self { + obj: any.into(), + advertising_status: AdvertisingStatus::NotAdvertising, + }) }) } @@ -111,28 +137,35 @@ impl Device { intern!(py, "from_config_file_with_hci"), (device_config, source.0, sink.0), ) - .map(|any| Self(any.into())) + .map(|any| Self { + obj: any.into(), + advertising_status: AdvertisingStatus::NotAdvertising, + }) }) } /// Create a Device configured to communicate with a controller through an HCI source/sink - pub fn with_hci(name: &str, address: &str, source: Source, sink: Sink) -> PyResult { + pub fn with_hci(name: &str, address: Address, source: Source, sink: Sink) -> PyResult { Python::with_gil(|py| { PyModule::import(py, intern!(py, "bumble.device"))? .getattr(intern!(py, "Device"))? - .call_method1(intern!(py, "with_hci"), (name, address, source.0, sink.0)) - .map(|any| Self(any.into())) + .call_method1(intern!(py, "with_hci"), (name, address.0, source.0, sink.0)) + .map(|any| Self { + obj: any.into(), + advertising_status: AdvertisingStatus::NotAdvertising, + }) }) } /// Sends an HCI command on this Device, returning the command's event result. - pub async fn send_command(&self, command: &Command, check_result: bool) -> PyResult { + pub async fn send_command(&self, command: Command, check_result: bool) -> PyResult { + let bumble_hci_command = HciCommand::try_from(command)?; Python::with_gil(|py| { - self.0 + self.obj .call_method1( py, intern!(py, "send_command"), - (HciCommandWrapper(command.clone()), check_result), + (bumble_hci_command, check_result), ) .and_then(|coroutine| into_future(coroutine.as_ref(py))) })? @@ -151,7 +184,7 @@ impl Device { /// Turn the device on pub async fn power_on(&self) -> PyResult<()> { Python::with_gil(|py| { - self.0 + self.obj .call_method0(py, intern!(py, "power_on")) .and_then(|coroutine| into_future(coroutine.as_ref(py))) })? @@ -162,7 +195,7 @@ impl Device { /// Connect to a peer pub async fn connect(&self, peer_addr: &str) -> PyResult { Python::with_gil(|py| { - self.0 + self.obj .call_method1(py, intern!(py, "connect"), (peer_addr,)) .and_then(|coroutine| into_future(coroutine.as_ref(py))) })? @@ -180,7 +213,7 @@ impl Device { }); Python::with_gil(|py| { - self.0 + self.obj .call_method1(py, intern!(py, "add_listener"), ("connection", boxed)) }) .map(|_| ()) @@ -191,7 +224,7 @@ impl Device { Python::with_gil(|py| { let kwargs = PyDict::new(py); kwargs.set_item("filter_duplicates", filter_duplicates)?; - self.0 + self.obj .call_method(py, intern!(py, "start_scanning"), (), Some(kwargs)) .and_then(|coroutine| into_future(coroutine.as_ref(py))) })? @@ -209,7 +242,7 @@ impl Device { }); Python::with_gil(|py| { - self.0 + self.obj .call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed)) }) .map(|_| ()) @@ -218,7 +251,7 @@ impl Device { /// Set the advertisement data to be used when [Device::start_advertising] is called. pub fn set_advertising_data(&mut self, adv_data: AdvertisementDataBuilder) -> PyResult<()> { Python::with_gil(|py| { - self.0.setattr( + self.obj.setattr( py, intern!(py, "advertising_data"), adv_data.into_bytes().as_slice(), @@ -230,7 +263,7 @@ impl Device { /// Returns the host used by the device, if any pub fn host(&mut self) -> PyResult> { Python::with_gil(|py| { - self.0 + self.obj .getattr(py, intern!(py, "host")) .map(|obj| obj.into_option(Host::from)) }) @@ -238,27 +271,151 @@ impl Device { /// Start advertising the data set with [Device.set_advertisement]. pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> { + if self.advertising_status == AdvertisingStatus::AdvertisingExtended { + return Err(PyErr::new::("Already advertising in extended mode. Stop the existing extended advertisement to start a legacy advertisement.")); + } + Python::with_gil(|py| { let kwargs = PyDict::new(py); kwargs.set_item("auto_restart", auto_restart)?; - self.0 + self.obj .call_method(py, intern!(py, "start_advertising"), (), Some(kwargs)) .and_then(|coroutine| into_future(coroutine.as_ref(py))) })? .await - .map(|_| ()) + .map(|_| ())?; + + self.advertising_status = AdvertisingStatus::AdvertisingLegacy; + Ok(()) + } + + /// Start advertising the data set in extended mode, replacing any existing extended adv. The + /// advertisement will be non-connectable. + /// + /// Fails if the device is already advertising in legacy mode. + #[cfg(feature = "unstable_extended_adv")] + pub async fn start_advertising_extended( + &mut self, + adv_data: AdvertisementDataBuilder, + ) -> PyResult<()> { + // TODO: add tests when local controller object supports extended advertisement commands (github.com/google/bumble/pull/238) + match self.advertising_status { + AdvertisingStatus::AdvertisingLegacy => return Err(PyErr::new::("Already advertising in legacy mode. Stop the existing legacy advertisement to start an extended advertisement.")), + // Stop the current extended advertisement before advertising with new data. + // We could just issue an LeSetExtendedAdvertisingData command, but this approach + // allows better future flexibility if `start_advertising_extended` were to change. + AdvertisingStatus::AdvertisingExtended => self.stop_advertising_extended().await?, + _ => {} + } + + // if you change this, don't forget to change the same handle in `stop_advertising_extended` + let advertising_handle = 0x00; + + // set extended params + let properties = AdvertisingEventProperties { + connectable: 0, + scannable: 0, + directed: 0, + high_duty_cycle: 0, + legacy: 0, + anonymous: 0, + tx_power: 0, + }; + let extended_advertising_params_cmd = LeSetExtendedAdvertisingParametersBuilder { + advertising_event_properties: properties, + advertising_filter_policy: AdvertisingFilterPolicy::AllDevices, + advertising_handle, + advertising_sid: 0, + advertising_tx_power: 0, + own_address_type: OwnAddressType::RandomDeviceAddress, + peer_address: default_ignored_peer_address(), + peer_address_type: PeerAddressType::PublicDeviceOrIdentityAddress, + primary_advertising_channel_map: 7, + primary_advertising_interval_max: 200, + primary_advertising_interval_min: 100, + primary_advertising_phy: PrimaryPhyType::Le1m, + scan_request_notification_enable: Enable::Disabled, + secondary_advertising_max_skip: 0, + secondary_advertising_phy: SecondaryPhyType::Le1m, + }; + self.send_command(extended_advertising_params_cmd.into(), true) + .await?; + + // set random address + let random_address: packets::Address = + self.random_address()?.try_into().map_err(|e| match e { + ConversionError::Python(pyerr) => pyerr, + ConversionError::Native(e) => PyErr::new::(format!("{e:?}")), + })?; + let random_address_cmd = LeSetAdvertisingSetRandomAddressBuilder { + advertising_handle, + random_address, + }; + self.send_command(random_address_cmd.into(), true).await?; + + // set adv data + let advertising_data_cmd = LeSetExtendedAdvertisingDataBuilder { + advertising_data: adv_data.into_bytes(), + advertising_handle, + fragment_preference: FragmentPreference::ControllerMayFragment, + operation: Operation::CompleteAdvertisement, + }; + self.send_command(advertising_data_cmd.into(), true).await?; + + // enable adv + let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder { + enable: Enable::Enabled, + enabled_sets: vec![EnabledSet { + advertising_handle, + duration: 0, + max_extended_advertising_events: 0, + }], + }; + self.send_command(extended_advertising_enable_cmd.into(), true) + .await?; + + self.advertising_status = AdvertisingStatus::AdvertisingExtended; + Ok(()) } /// Stop advertising. pub async fn stop_advertising(&mut self) -> PyResult<()> { Python::with_gil(|py| { - self.0 + self.obj .call_method0(py, intern!(py, "stop_advertising")) .and_then(|coroutine| into_future(coroutine.as_ref(py))) })? .await - .map(|_| ()) + .map(|_| ())?; + + if self.advertising_status == AdvertisingStatus::AdvertisingLegacy { + self.advertising_status = AdvertisingStatus::NotAdvertising; + } + Ok(()) + } + + /// Stop advertising extended. + #[cfg(feature = "unstable_extended_adv")] + pub async fn stop_advertising_extended(&mut self) -> PyResult<()> { + if AdvertisingStatus::AdvertisingExtended != self.advertising_status { + return Ok(()); + } + + // disable adv + let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder { + enable: Enable::Disabled, + enabled_sets: vec![EnabledSet { + advertising_handle: 0x00, + duration: 0, + max_extended_advertising_events: 0, + }], + }; + self.send_command(extended_advertising_enable_cmd.into(), true) + .await?; + + self.advertising_status = AdvertisingStatus::NotAdvertising; + Ok(()) } /// Registers an L2CAP connection oriented channel server. When a client connects to the server, @@ -286,7 +443,7 @@ impl Device { kwargs.set_opt_item("max_credits", max_credits)?; kwargs.set_opt_item("mtu", mtu)?; kwargs.set_opt_item("mps", mps)?; - self.0.call_method( + self.obj.call_method( py, intern!(py, "register_l2cap_channel_server"), (), @@ -295,6 +452,15 @@ impl Device { })?; Ok(()) } + + /// Gets the Device's `random_address` property + pub fn random_address(&self) -> PyResult
{ + Python::with_gil(|py| { + self.obj + .getattr(py, intern!(py, "random_address")) + .map(Address) + }) + } } /// A connection to a remote device. @@ -451,3 +617,13 @@ impl Advertisement { Python::with_gil(|py| self.0.getattr(py, intern!(py, "data")).map(AdvertisingData)) } } + +/// Use this address when sending an HCI command that requires providing a peer address, but the +/// command is such that the peer address will be ignored. +/// +/// Internal to bumble, this address might mean "any", but a packets::Address typically gets sent +/// directly to a controller, so we don't have to worry about it. +#[cfg(feature = "unstable_extended_adv")] +fn default_ignored_peer_address() -> packets::Address { + packets::Address::try_from(0x0000_0000_0000_u64).unwrap() +} diff --git a/rust/src/wrapper/device/tests.rs b/rust/src/wrapper/device/tests.rs new file mode 100644 index 00000000..648b919b --- /dev/null +++ b/rust/src/wrapper/device/tests.rs @@ -0,0 +1,23 @@ +// Copyright 2023 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 +// +// http://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. + +#[cfg(feature = "unstable_extended_adv")] +use crate::wrapper::device::default_ignored_peer_address; + +#[test] +#[cfg(feature = "unstable_extended_adv")] +fn default_peer_address_does_not_panic() { + let result = std::panic::catch_unwind(default_ignored_peer_address); + assert!(result.is_ok()) +} diff --git a/rust/src/wrapper/hci.rs b/rust/src/wrapper/hci.rs index b029a654..db09f16a 100644 --- a/rust/src/wrapper/hci.rs +++ b/rust/src/wrapper/hci.rs @@ -14,18 +14,19 @@ //! HCI +// re-export here, and internal usages of these imports should refer to this mod, not the internal +// mod +pub(crate) use crate::internal::hci::WithPacketType; pub use crate::internal::hci::{packets, Error, Packet}; -use crate::{ - internal::hci::WithPacketType, - wrapper::hci::packets::{AddressType, Command, ErrorCode}, +use crate::wrapper::{ + hci::packets::{AddressType, Command, ErrorCode}, + ConversionError, }; use itertools::Itertools as _; use pyo3::{ - exceptions::PyException, - intern, pyclass, pymethods, - types::{PyBytes, PyModule}, - FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, Python, ToPyObject, + exceptions::PyException, intern, types::PyModule, FromPyObject, IntoPy, PyAny, PyErr, PyObject, + PyResult, Python, ToPyObject, }; /// Provides helpers for interacting with HCI @@ -43,17 +44,45 @@ impl HciConstant { } } +/// Bumble's representation of an HCI command. +pub(crate) struct HciCommand(pub(crate) PyObject); + +impl HciCommand { + fn from_bytes(bytes: &[u8]) -> PyResult { + Python::with_gil(|py| { + PyModule::import(py, intern!(py, "bumble.hci"))? + .getattr(intern!(py, "HCI_Command"))? + .call_method1(intern!(py, "from_bytes"), (bytes,)) + .map(|obj| Self(obj.to_object(py))) + }) + } +} + +impl TryFrom for HciCommand { + type Error = PyErr; + + fn try_from(value: Command) -> Result { + HciCommand::from_bytes(&value.to_vec_with_packet_type()) + } +} + +impl IntoPy for HciCommand { + fn into_py(self, _py: Python<'_>) -> PyObject { + self.0 + } +} + /// A Bluetooth address #[derive(Clone)] pub struct Address(pub(crate) PyObject); impl Address { - /// Creates a new [Address] object - pub fn new(address: &str, address_type: &AddressType) -> PyResult { + /// Creates a new [Address] object. + pub fn new(address: &str, address_type: AddressType) -> PyResult { Python::with_gil(|py| { PyModule::import(py, intern!(py, "bumble.device"))? .getattr(intern!(py, "Address"))? - .call1((address, address_type.to_object(py))) + .call1((address, address_type)) .map(|any| Self(any.into())) }) } @@ -118,27 +147,28 @@ impl ToPyObject for Address { } } -/// Implements minimum necessary interface to be treated as bumble's [HCI_Command]. -/// While pyo3's macros do not support generics, this could probably be refactored to allow multiple -/// implementations of the HCI_Command methods in the future, if needed. -#[pyclass] -pub(crate) struct HciCommandWrapper(pub(crate) Command); - -#[pymethods] -impl HciCommandWrapper { - fn __bytes__(&self, py: Python) -> PyResult { - let bytes = PyBytes::new(py, &self.0.clone().to_vec_with_packet_type()); - Ok(bytes.into_py(py)) - } +/// An error meaning that the u64 value did not represent a valid BT address. +#[derive(Debug)] +pub struct InvalidAddress(u64); - #[getter] - fn op_code(&self) -> u16 { - self.0.get_op_code().into() +impl TryInto for Address { + type Error = ConversionError; + + fn try_into(self) -> Result { + let addr_le_bytes = self.as_le_bytes().map_err(ConversionError::Python)?; + + let mut buf = [0_u8; 8]; + buf[0..6].copy_from_slice(&addr_le_bytes); + let address_u64 = u64::from_le_bytes(buf); + + packets::Address::try_from(address_u64) + .map_err(InvalidAddress) + .map_err(ConversionError::Native) } } -impl ToPyObject for AddressType { - fn to_object(&self, py: Python<'_>) -> PyObject { +impl IntoPy for AddressType { + fn into_py(self, py: Python<'_>) -> PyObject { u8::from(self).to_object(py) } } diff --git a/rust/src/wrapper/mod.rs b/rust/src/wrapper/mod.rs index 27b86d97..afe437da 100644 --- a/rust/src/wrapper/mod.rs +++ b/rust/src/wrapper/mod.rs @@ -132,3 +132,12 @@ pub(crate) fn wrap_python_async<'a>(py: Python<'a>, function: &'a PyAny) -> PyRe .getattr(intern!(py, "wrap_async"))? .call1((function,)) } + +/// Represents the two major kinds of errors that can occur when converting between Rust and Python. +pub enum ConversionError { + /// Occurs across the Python/native boundary. + Python(PyErr), + /// Occurs within the native ecosystem, such as when performing more transformations before + /// finally converting to the native type. + Native(T), +} From 1004f10384ad65a5845756779308cbf93a1fbd11 Mon Sep 17 00:00:00 2001 From: Gabriel White-Vega Date: Tue, 10 Oct 2023 15:26:42 -0400 Subject: [PATCH 2/3] Address PR comments --- rust/src/wrapper/device/mod.rs | 25 +++++++++++++++++-------- rust/src/wrapper/hci.rs | 3 +++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/rust/src/wrapper/device/mod.rs b/rust/src/wrapper/device/mod.rs index e040a89d..82a274a0 100644 --- a/rust/src/wrapper/device/mod.rs +++ b/rust/src/wrapper/device/mod.rs @@ -97,6 +97,9 @@ pub struct Device { } impl Device { + #[cfg(feature = "unstable_extended_adv")] + const ADVERTISING_HANDLE_EXTENDED: u8 = 0x00; + /// Creates a Device. When optional arguments are not specified, the Python object specifies the /// defaults. pub fn new( @@ -158,6 +161,9 @@ impl Device { } /// Sends an HCI command on this Device, returning the command's event result. + /// + /// When `check_result` is `true`, then an `Err` will be returned if the controller's response + /// did not have an event code of "success". pub async fn send_command(&self, command: Command, check_result: bool) -> PyResult { let bumble_hci_command = HciCommand::try_from(command)?; Python::with_gil(|py| { @@ -270,10 +276,16 @@ impl Device { } /// Start advertising the data set with [Device.set_advertisement]. + /// + /// When `auto_restart` is set to `true`, then the device will automatically restart advertising + /// when a connected device is disconnected. pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> { if self.advertising_status == AdvertisingStatus::AdvertisingExtended { return Err(PyErr::new::("Already advertising in extended mode. Stop the existing extended advertisement to start a legacy advertisement.")); } + // Bumble allows (and currently ignores) calling `start_advertising` when already + // advertising. Because that behavior may change in the future, we continue to delegate the + // handling to bumble. Python::with_gil(|py| { let kwargs = PyDict::new(py); @@ -309,9 +321,6 @@ impl Device { _ => {} } - // if you change this, don't forget to change the same handle in `stop_advertising_extended` - let advertising_handle = 0x00; - // set extended params let properties = AdvertisingEventProperties { connectable: 0, @@ -325,7 +334,7 @@ impl Device { let extended_advertising_params_cmd = LeSetExtendedAdvertisingParametersBuilder { advertising_event_properties: properties, advertising_filter_policy: AdvertisingFilterPolicy::AllDevices, - advertising_handle, + advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED, advertising_sid: 0, advertising_tx_power: 0, own_address_type: OwnAddressType::RandomDeviceAddress, @@ -349,7 +358,7 @@ impl Device { ConversionError::Native(e) => PyErr::new::(format!("{e:?}")), })?; let random_address_cmd = LeSetAdvertisingSetRandomAddressBuilder { - advertising_handle, + advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED, random_address, }; self.send_command(random_address_cmd.into(), true).await?; @@ -357,7 +366,7 @@ impl Device { // set adv data let advertising_data_cmd = LeSetExtendedAdvertisingDataBuilder { advertising_data: adv_data.into_bytes(), - advertising_handle, + advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED, fragment_preference: FragmentPreference::ControllerMayFragment, operation: Operation::CompleteAdvertisement, }; @@ -367,7 +376,7 @@ impl Device { let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder { enable: Enable::Enabled, enabled_sets: vec![EnabledSet { - advertising_handle, + advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED, duration: 0, max_extended_advertising_events: 0, }], @@ -406,7 +415,7 @@ impl Device { let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder { enable: Enable::Disabled, enabled_sets: vec![EnabledSet { - advertising_handle: 0x00, + advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED, duration: 0, max_extended_advertising_events: 0, }], diff --git a/rust/src/wrapper/hci.rs b/rust/src/wrapper/hci.rs index db09f16a..533fe21b 100644 --- a/rust/src/wrapper/hci.rs +++ b/rust/src/wrapper/hci.rs @@ -157,6 +157,9 @@ impl TryInto for Address { fn try_into(self) -> Result { let addr_le_bytes = self.as_le_bytes().map_err(ConversionError::Python)?; + // packets::Address only supports converting from a u64 (TODO: update if/when it supports converting from [u8; 6] -- https://github.com/google/pdl/issues/75) + // So first we take the python `Address` little-endian bytes (6 bytes), copy them into a + // [u8; 8] in little-endian format, and finally convert it into a u64. let mut buf = [0_u8; 8]; buf[0..6].copy_from_slice(&addr_le_bytes); let address_u64 = u64::from_le_bytes(buf); From 59d7717963651a756761974e2e4ab12ccc4ef6d5 Mon Sep 17 00:00:00 2001 From: Gabriel White-Vega Date: Wed, 18 Oct 2023 15:39:37 -0400 Subject: [PATCH 3/3] Remove mutable ret pattern and test feature combinations After adding test for feature combinations, I found a corner case where, when Transport is dropped and the process is terminated in a test, the `close` Python future is not awaited. I don't know what other situations this issue may arise, so I have safe-guarded it via `block_on` instead of spawning a thread. --- .github/workflows/python-build-test.yml | 8 +++++--- bumble/controller.py | 19 +++++++++---------- rust/Cargo.toml | 11 +++++++++-- rust/src/wrapper/transport.rs | 7 ++++--- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml index 4cc3e73d..fb1e0a89 100644 --- a/.github/workflows/python-build-test.yml +++ b/.github/workflows/python-build-test.yml @@ -56,7 +56,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install Python dependencies run: | python -m pip install --upgrade pip python -m pip install ".[build,test,development,documentation]" @@ -65,15 +65,17 @@ jobs: with: components: clippy,rustfmt toolchain: ${{ matrix.rust-version }} + - name: Install Rust dependencies + run: cargo install cargo-all-features # allows building/testing combinations of features - name: Check License Headers run: cd rust && cargo run --features dev-tools --bin file-header check-all - name: Rust Build - run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets + run: cd rust && cargo build --all-targets && cargo build-all-features --all-targets # Lints after build so what clippy needs is already built - name: Rust Lints run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings - name: Rust Tests - run: cd rust && cargo test + run: cd rust && cargo test-all-features # At some point, hook up publishing the binary. For now, just make sure it builds. # Once we're ready to publish binaries, this should be built with `--release`. - name: Build Bumble CLI diff --git a/bumble/controller.py b/bumble/controller.py index baa07469..d035bcca 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -1000,16 +1000,15 @@ def on_hci_le_set_scan_parameters_command(self, command): ''' See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command ''' - ret = HCI_SUCCESS - if not self.le_scan_enable: - self.le_scan_type = command.le_scan_type - self.le_scan_interval = command.le_scan_interval - self.le_scan_window = command.le_scan_window - self.le_scan_own_address_type = command.own_address_type - self.le_scanning_filter_policy = command.scanning_filter_policy - else: - ret = HCI_COMMAND_DISALLOWED_ERROR - return bytes([ret]) + if self.le_scan_enable: + return bytes([HCI_COMMAND_DISALLOWED_ERROR]) + + self.le_scan_type = command.le_scan_type + self.le_scan_interval = command.le_scan_interval + self.le_scan_window = command.le_scan_window + self.le_scan_own_address_type = command.own_address_type + self.le_scanning_filter_policy = command.scanning_filter_policy + return bytes([HCI_SUCCESS]) def on_hci_le_set_scan_enable_command(self, command): ''' diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a3c63ba9..7e119660 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -12,6 +12,13 @@ keywords = ["bluetooth", "ble"] categories = ["api-bindings", "network-programming"] rust-version = "1.70.0" +# https://github.com/frewsxcv/cargo-all-features#options +[package.metadata.cargo-all-features] +# We are interested in testing subset combinations of this feature, so this is redundant +denylist = ["unstable"] +# To exercise combinations of any of these features, remove from `always_include_features` +always_include_features = ["anyhow", "pyo3-asyncio-attributes", "dev-tools", "bumble-tools"] + [dependencies] pyo3 = { version = "0.18.3", features = ["macros"] } pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] } @@ -26,6 +33,7 @@ thiserror = "1.0.41" bytes = "1.5.0" pdl-derive = "0.2.0" pdl-runtime = "0.2.0" +futures = "0.3.28" # Dev tools file-header = { version = "0.1.2", optional = true } @@ -36,7 +44,6 @@ anyhow = { version = "1.0.71", optional = true } clap = { version = "4.3.3", features = ["derive"], optional = true } directories = { version = "5.0.1", optional = true } env_logger = { version = "0.10.0", optional = true } -futures = { version = "0.3.28", optional = true } log = { version = "0.4.19", optional = true } owo-colors = { version = "3.5.0", optional = true } reqwest = { version = "0.11.20", features = ["blocking"], optional = true } @@ -90,7 +97,7 @@ anyhow = ["pyo3/anyhow"] pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"] dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"] # separate feature for CLI so that dependencies don't spend time building these -bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"] +bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger"] # all the unstable features unstable = ["unstable_extended_adv"] diff --git a/rust/src/wrapper/transport.rs b/rust/src/wrapper/transport.rs index a7ec9e9d..8c626872 100644 --- a/rust/src/wrapper/transport.rs +++ b/rust/src/wrapper/transport.rs @@ -15,6 +15,7 @@ //! HCI packet transport use crate::wrapper::controller::Controller; +use futures::executor::block_on; use pyo3::{intern, types::PyModule, PyObject, PyResult, Python}; /// A source/sink pair for HCI packet I/O. @@ -58,9 +59,9 @@ impl Transport { impl Drop for Transport { fn drop(&mut self) { - // can't await in a Drop impl, but we can at least spawn a task to do it - let obj = self.0.clone(); - tokio::spawn(async move { Self(obj).close().await }); + // don't spawn a thread to handle closing, as it may get dropped at program termination, + // resulting in `RuntimeWarning: coroutine ... was never awaited` from Python + let _ = block_on(self.close()); } }