Merge pull request #316 from whitevegagabriel/extended

Add support for extended advertising via Rust-only API
This commit is contained in:
Gilles Boccon-Gibod
2023-11-09 13:43:00 -08:00
committed by GitHub
18 changed files with 533 additions and 165 deletions
+5 -3
View File
@@ -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
+3
View File
@@ -1000,6 +1000,9 @@ class Controller:
'''
See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
'''
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
+19 -2
View File
@@ -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 }
@@ -74,6 +81,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"
@@ -85,5 +97,10 @@ 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"]
unstable_extended_adv = []
default = []
+3 -6
View File
@@ -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?;
+21 -5
View File
@@ -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.
///
/// <https://google.github.io/bumble/transports/index.html>
#[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,
+5 -7
View File
@@ -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
-77
View File
@@ -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::<PyException, _>(e.to_string()))?;
assert_eq!(ErrorCode::Success, event.get_status());
Ok(())
}
+22
View File
@@ -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(())
}
+86
View File
@@ -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::<PyException, _>(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::<PyException, _>(e.to_string()))?;
assert_eq!(ErrorCode::CommandDisallowed, event.get_status());
Ok(())
}
async fn create_local_device(address: Address) -> PyResult<Device> {
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)
}
+17
View File
@@ -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;
+31
View File
@@ -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(())
}
+7 -5
View File
@@ -94,7 +94,7 @@ impl From<Error> for PacketTypeParseError {
impl WithPacketType<Self> for Command {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Command, self.to_vec())
prepend_packet_type(PacketType::Command, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -104,7 +104,7 @@ impl WithPacketType<Self> for Command {
impl WithPacketType<Self> for Acl {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Acl, self.to_vec())
prepend_packet_type(PacketType::Acl, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -114,7 +114,7 @@ impl WithPacketType<Self> for Acl {
impl WithPacketType<Self> for Sco {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Sco, self.to_vec())
prepend_packet_type(PacketType::Sco, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -124,7 +124,7 @@ impl WithPacketType<Self> for Sco {
impl WithPacketType<Self> for Event {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Event, self.to_vec())
prepend_packet_type(PacketType::Event, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -132,7 +132,9 @@ impl WithPacketType<Self> for Event {
}
}
fn prepend_packet_type(packet_type: PacketType, mut packet_bytes: Vec<u8>) -> Vec<u8> {
fn prepend_packet_type<T: Packet>(packet_type: PacketType, packet: T) -> Vec<u8> {
// 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
}
+10 -7
View File
@@ -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<u8>,
}
impl FakePacket {
fn parse(_bytes: &[u8]) -> Result<Self, Error> {
Ok(Self)
fn parse(bytes: &[u8]) -> Result<Self, Error> {
Ok(Self {
bytes: bytes.to_vec(),
})
}
}
@@ -89,6 +92,6 @@ impl Packet for FakePacket {
}
fn to_vec(self) -> Vec<u8> {
Vec::new()
self.bytes
}
}
@@ -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,11 +82,24 @@ 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 {
#[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(
@@ -94,7 +120,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 +140,38 @@ 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<Self> {
pub fn with_hci(name: &str, address: Address, source: Source, sink: Sink) -> PyResult<Self> {
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<Event> {
///
/// 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<Event> {
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 +190,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 +201,7 @@ impl Device {
/// Connect to a peer
pub async fn connect(&self, peer_addr: &str) -> PyResult<Connection> {
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 +219,7 @@ impl Device {
});
Python::with_gil(|py| {
self.0
self.obj
.call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
})
.map(|_| ())
@@ -191,7 +230,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 +248,7 @@ impl Device {
});
Python::with_gil(|py| {
self.0
self.obj
.call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed))
})
.map(|_| ())
@@ -218,7 +257,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,35 +269,162 @@ impl Device {
/// Returns the host used by the device, if any
pub fn host(&mut self) -> PyResult<Option<Host>> {
Python::with_gil(|py| {
self.0
self.obj
.getattr(py, intern!(py, "host"))
.map(|obj| obj.into_option(Host::from))
})
}
/// 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::<PyException, _>("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);
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::<PyException, _>("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?,
_ => {}
}
// 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: Self::ADVERTISING_HANDLE_EXTENDED,
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::<PyException, _>(format!("{e:?}")),
})?;
let random_address_cmd = LeSetAdvertisingSetRandomAddressBuilder {
advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
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: Self::ADVERTISING_HANDLE_EXTENDED,
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: Self::ADVERTISING_HANDLE_EXTENDED,
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: Self::ADVERTISING_HANDLE_EXTENDED,
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 +452,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 +461,15 @@ impl Device {
})?;
Ok(())
}
/// Gets the Device's `random_address` property
pub fn random_address(&self) -> PyResult<Address> {
Python::with_gil(|py| {
self.obj
.getattr(py, intern!(py, "random_address"))
.map(Address)
})
}
}
/// A connection to a remote device.
@@ -451,3 +626,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()
}
+23
View File
@@ -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())
}
+59 -26
View File
@@ -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<Self> {
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<Command> for HciCommand {
type Error = PyErr;
fn try_from(value: Command) -> Result<Self, Self::Error> {
HciCommand::from_bytes(&value.to_vec_with_packet_type())
}
}
impl IntoPy<PyObject> 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<Self> {
/// Creates a new [Address] object.
pub fn new(address: &str, address_type: AddressType) -> PyResult<Self> {
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,31 @@ 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);
/// An error meaning that the u64 value did not represent a valid BT address.
#[derive(Debug)]
pub struct InvalidAddress(u64);
#[pymethods]
impl HciCommandWrapper {
fn __bytes__(&self, py: Python) -> PyResult<PyObject> {
let bytes = PyBytes::new(py, &self.0.clone().to_vec_with_packet_type());
Ok(bytes.into_py(py))
}
impl TryInto<packets::Address> for Address {
type Error = ConversionError<InvalidAddress>;
#[getter]
fn op_code(&self) -> u16 {
self.0.get_op_code().into()
fn try_into(self) -> Result<packets::Address, Self::Error> {
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);
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<PyObject> for AddressType {
fn into_py(self, py: Python<'_>) -> PyObject {
u8::from(self).to_object(py)
}
}
+9
View File
@@ -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<T> {
/// 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),
}
+4 -3
View File
@@ -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());
}
}