rust/bt-battery: Initial commit of the battery client - Define a `BatteryMonitor` which can be used to scan for compatible LE peers that support the BT Battery Service (BAS). - Provide a mechanism for connecting to a compatible peer. - Read and save the current battery level once a GATT connection is made. - Add test utilities for setting up a mock peer battery service. Test: cargo test Change-Id: I643af01012c0487e11b5fa09afd49b96616f045c Reviewed-on: https://bluetooth-review.git.corp.google.com/c/bluetooth/+/1660 Reviewed-by: Dayeong Lee <dayeonglee@google.com>
diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 8b01f6f..8aaf960 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml
@@ -13,6 +13,7 @@ ## Local path dependencies (keep sorted) bt-bap = { path = "bt-bap" } bt-bass = { path = "bt-bass" } +bt-battery = { path = "bt-battery" } bt-broadcast-assistant = { path = "bt-broadcast-assistant" } bt-common = { path = "bt-common" } bt-gatt = { path = "bt-gatt" }
diff --git a/rust/bt-battery/.gitignore b/rust/bt-battery/.gitignore new file mode 100644 index 0000000..438e2b1 --- /dev/null +++ b/rust/bt-battery/.gitignore
@@ -0,0 +1,17 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Vim swap files. +*.swp
diff --git a/rust/bt-battery/Cargo.toml b/rust/bt-battery/Cargo.toml new file mode 100644 index 0000000..b91ea77 --- /dev/null +++ b/rust/bt-battery/Cargo.toml
@@ -0,0 +1,13 @@ +[package] +name = "bt-battery" +description = "Client and server library for the Battery Service" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +bt-common.workspace = true +bt-gatt = { workspace = true , features = ["test-utils"] } +futures.workspace = true +parking_lot.workspace = true +thiserror.workspace = true
diff --git a/rust/bt-battery/src/error.rs b/rust/bt-battery/src/error.rs new file mode 100644 index 0000000..0db99af --- /dev/null +++ b/rust/bt-battery/src/error.rs
@@ -0,0 +1,41 @@ +// Copyright 2024 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use bt_common::packet_encoding::Error as PacketError; +use bt_gatt::types::{Error as GattLibraryError, GattError}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("LE Scan for battery services has already started.")] + ScanAlreadyStarted, + + #[error("GATT operation error: {0:?}")] + Gatt(#[from] GattError), + + #[error("GATT library error: {0:?}")] + GattLibrary(#[from] GattLibraryError), + + #[error("No compatible battery service found")] + ServiceNotFound, + + #[error("Packet serialization/deserialization error: {0}")] + Packet(#[from] PacketError), + + #[error("Malformed service on peer: {0}")] + Service(ServiceError), + + #[error("Generic failure: {0}")] + Generic(String), +} + +/// Errors found when interacting with the remote peer's battery service. +#[derive(Debug, Error, PartialEq)] +pub enum ServiceError { + #[error("Missing a required service characteristic")] + MissingCharacteristic, + + #[error("Notification streams unexpectedly terminated")] + NotificationStreamClosed, +}
diff --git a/rust/bt-battery/src/lib.rs b/rust/bt-battery/src/lib.rs new file mode 100644 index 0000000..ac20b57 --- /dev/null +++ b/rust/bt-battery/src/lib.rs
@@ -0,0 +1,13 @@ +// Copyright 2024 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// An error type for the battery service crate. +mod error; +pub use error::Error; + +/// Implements the Battery Service monitor (client) role. +pub mod monitor; + +/// Common types used throughout the battery service crate. +pub mod types;
diff --git a/rust/bt-battery/src/monitor.rs b/rust/bt-battery/src/monitor.rs new file mode 100644 index 0000000..cbe5a64 --- /dev/null +++ b/rust/bt-battery/src/monitor.rs
@@ -0,0 +1,192 @@ +// Copyright 2024 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +//! Implements the Bluetooth Battery Service (BAS) client role. +//! +//! Use the toplevel `BatteryMonitor` to construct a new battery monitoring +//! client. The client will scan for compatible peers that support the Battery +//! Service. When a compatible peer is found, use `BatteryMonitor::connect` to +//! establish a GATT connection to the peer's service. The battery level and +//! other characteristics are available in the returned `BatteryMonitorClient`. +//! +//! For example: +//! +//! // Set up a GATT Central and construct the battery monitor. +//! let central = ...; +//! let monitor = BatteryMonitor::new(central); +//! +//! // Start scanning for compatible peers and find the next available one. +//! let mut scan_results = monitor.start()?; +//! let compatible_peer_id = scan_results.next().await?; +//! +//! // Connect to the peer's Battery Service. +//! let connected_service = monitor.connect(compatible_peer_id).await?; +//! +//! // Process battery events (notifications/indications) from the peer. +//! let battery_events = connected_service.take_event_stream()?; +//! while let Some(battery_event) = battery_events.next().await? { +//! // Do something with `battery_event` +//! } + +use bt_common::PeerId; +use bt_gatt::central::{Filter, ScanFilter}; +use bt_gatt::client::PeerServiceHandle; +use bt_gatt::{Central, Client, GattTypes}; + +use crate::error::Error; +use crate::types::BATTERY_SERVICE_UUID; + +mod client; +pub use client::{BatteryMonitorClient, BatteryMonitorEventStream}; + +/// Monitors the battery properties on a remote peer's Battery Service (BAS). +/// +/// Use `BatteryMonitor::start` to scan for compatible peers that provide the +/// battery service. Once a suitable peer has been found, use +/// `BatteryMonitor::connect` to initiate an outbound connection to the peer's +/// Battery GATT service. Use the returned `BatteryMonitorClient` to +/// interact with the GATT service (e.g. read battery level, receive battery +/// level updates, etc.). +pub struct BatteryMonitor<T: GattTypes> { + central: T::Central, + scan_stream: Option<T::ScanResultStream>, +} + +impl<T: GattTypes> BatteryMonitor<T> { + pub fn new(central: T::Central) -> Self { + let scan_stream = central.scan(&Self::scan_filter()); + Self { central, scan_stream: Some(scan_stream) } + } + + fn scan_filter() -> Vec<ScanFilter> { + vec![ScanFilter { + filters: vec![Filter::ServiceUuid(BATTERY_SERVICE_UUID), Filter::IsConnectable], + }] + } + + /// Start scanning for compatible Battery peers. + /// Returns a stream of scan results on success, Error if the scan couldn't + /// complete for any reason. + /// Can only be called once, returns `Error::ScanAlreadyStarted` on + /// subsequent attempts. + pub fn start(&mut self) -> Result<T::ScanResultStream, Error> + where + <T as bt_gatt::GattTypes>::ScanResultStream: std::marker::Send, + { + let Some(scan_stream) = self.scan_stream.take() else { + return Err(Error::ScanAlreadyStarted); + }; + + Ok(scan_stream) + } + + /// Attempts to connect to the remote peer's Battery service. + /// Returns a battery monitor which can be used to interact with the peer's + /// battery service on success, Error if the connection couldn't be made + /// or if the peer's Battery service is invalid. + pub async fn connect(&mut self, id: PeerId) -> Result<BatteryMonitorClient<T>, Error> + where + <T as bt_gatt::GattTypes>::NotificationStream: std::marker::Send, + { + let client = self.central.connect(id).await.map_err(Error::GattLibrary)?; + let peer_service_handles = + client.find_service(BATTERY_SERVICE_UUID).await.map_err(Error::GattLibrary)?; + + for handle in peer_service_handles { + if handle.uuid() != BATTERY_SERVICE_UUID || !handle.is_primary() { + return Err(Error::ServiceNotFound); + } + let service = handle.connect().await.map_err(Error::GattLibrary)?; + let monitor = BatteryMonitorClient::<T>::create(client, service).await?; + // TODO(b/335246946): This short circuits after the first valid service is + // found. Expand this to read all of the battery services to provide + // an aggregated view of the peer. + return Ok(monitor); + } + Err(Error::ServiceNotFound) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use bt_common::Uuid; + use bt_gatt::test_utils::{FakeCentral, FakeClient, FakeTypes}; + use futures::{pin_mut, FutureExt}; + use std::task::Poll; + + use crate::monitor::client::tests::fake_battery_service; + + #[test] + fn battery_monitor_start_scan() { + let fake_central = FakeCentral::new(); + let mut monitor = BatteryMonitor::<FakeTypes>::new(fake_central); + let _monitor_scan_stream = monitor.start().expect("can start scanning"); + + // The scan stream can only be acquired once. + assert!(monitor.start().is_err()); + } + + #[test] + fn battery_monitor_connect_success() { + // Instantiate a fake client with a battery service. + let id = PeerId(1); + let mut fake_central = FakeCentral::new(); + let mut fake_client = FakeClient::new(); + fake_central.add_client(id, fake_client.clone()); + let fake_battery_service = fake_battery_service(50); + fake_client.add_service(BATTERY_SERVICE_UUID, true, fake_battery_service); + + let mut monitor = BatteryMonitor::<FakeTypes>::new(fake_central); + + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + let connect_fut = monitor.connect(id); + pin_mut!(connect_fut); + let polled = connect_fut.poll_unpin(&mut noop_cx); + let Poll::Ready(Ok(_monitor_service)) = polled else { + panic!("Expected connect success"); + }; + } + + #[test] + fn connect_no_services_is_error() { + let id = PeerId(1); + let mut fake_central = FakeCentral::new(); + let fake_client = FakeClient::new(); + fake_central.add_client(id, fake_client.clone()); + // No battery service. + + let mut monitor = BatteryMonitor::<FakeTypes>::new(fake_central); + + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + let connect_fut = monitor.connect(id); + pin_mut!(connect_fut); + let polled = connect_fut.poll_unpin(&mut noop_cx); + let Poll::Ready(Err(Error::ServiceNotFound)) = polled else { + panic!("Expected connect failure"); + }; + } + + #[test] + fn connect_invalid_battery_service_is_error() { + let id = PeerId(1); + let mut fake_central = FakeCentral::new(); + let mut fake_client = FakeClient::new(); + fake_central.add_client(id, fake_client.clone()); + let fake_battery_service = fake_battery_service(50); + let random_uuid = Uuid::from_u16(0x1234); + fake_client.add_service(random_uuid, true, fake_battery_service); + + let mut monitor = BatteryMonitor::<FakeTypes>::new(fake_central); + + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + let connect_fut = monitor.connect(id); + pin_mut!(connect_fut); + let polled = connect_fut.poll_unpin(&mut noop_cx); + let Poll::Ready(Err(Error::ServiceNotFound)) = polled else { + panic!("Expected connect failure"); + }; + } +}
diff --git a/rust/bt-battery/src/monitor/client.rs b/rust/bt-battery/src/monitor/client.rs new file mode 100644 index 0000000..70ce184 --- /dev/null +++ b/rust/bt-battery/src/monitor/client.rs
@@ -0,0 +1,207 @@ +// Copyright 2024 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use bt_common::packet_encoding::Decodable; +use bt_gatt::client::ServiceCharacteristic; +use bt_gatt::types::Handle; +use bt_gatt::GattTypes; + +use crate::error::{Error, ServiceError}; +use crate::types::{BatteryLevel, BATTERY_LEVEL_UUID, READ_CHARACTERISTIC_BUFFER_SIZE}; + +// TODO(aniramakri): Implement this. +pub struct BatteryMonitorEventStream {} + +/// Implements the Battery Service client role. +pub struct BatteryMonitorClient<T: GattTypes> { + /// Represents the underlying GATT LE connection. Kept alive to maintain the + /// connection to the peer. + _client: T::Client, + /// GATT client interface for interacting with the peer's battery service. + gatt_client: T::PeerService, + /// GATT Handles associated with the peer's one or more Battery Level + /// characteristics. The first `Handle` in this list is expected to be + /// the "primary" one. + battery_level_handles: Vec<Handle>, + // TODO(b/335259516): Save Handles for additional characteristics that are discovered. + /// The current battery level reported by the peer's battery server. + battery_level: BatteryLevel, +} + +impl<T: GattTypes> BatteryMonitorClient<T> { + pub(crate) async fn create( + _client: T::Client, + gatt_client: T::PeerService, + ) -> Result<Self, Error> + where + <T as bt_gatt::GattTypes>::NotificationStream: std::marker::Send, + { + // All battery services must contain at least one battery level characteristic. + let battery_level_characteristics = + ServiceCharacteristic::<T>::find(&gatt_client, BATTERY_LEVEL_UUID).await?; + + if battery_level_characteristics.is_empty() { + return Err(Error::Service(ServiceError::MissingCharacteristic)); + } + // It is valid to have multiple Battery Level Characteristics. If multiple + // exist, the primary (main) characteristic has a Description field of + // "main". For now, we assume the first such characteristic is the + // primary. See BAS 1.1 Section 3.1.2.1. TODO(b/335246946): Check for + // Characteristic Presentation Format descriptor if + // multiple characteristics are present. Use this to infer the "primary". + let battery_level_handles: Vec<Handle> = + battery_level_characteristics.iter().map(|c| *c.handle()).collect(); + // Get the current battery level of the primary characteristic. + let (battery_level, _decoded_bytes) = { + let mut buf = vec![0; READ_CHARACTERISTIC_BUFFER_SIZE]; + let read_bytes = + battery_level_characteristics.first().unwrap().read(&mut buf[..]).await?; + BatteryLevel::decode(&buf[0..read_bytes])? + }; + + // TODO(aniramakri): Subscribe to notifications on the battery level + // characteristic and save as a stream of events. + + Ok(Self { _client, gatt_client, battery_level_handles, battery_level }) + } + + /// Returns a stream of battery events. + /// The returned Stream _must_ be polled in order to receive the relevant + /// notification and indications on the battery service. + /// This method should only be called once. + /// Returns Some<T> if the battery stream is available, None otherwise. + pub fn take_event_stream(&mut self) -> Option<BatteryMonitorEventStream> { + todo!("Implement battery event stream") + } + + #[cfg(test)] + fn battery_level(&self) -> BatteryLevel { + self.battery_level + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + use bt_common::packet_encoding::Error as PacketError; + use bt_common::Uuid; + use bt_gatt::test_utils::{FakeClient, FakePeerService, FakeTypes}; + use bt_gatt::types::{ + AttributePermissions, Characteristic, CharacteristicProperties, CharacteristicProperty, + }; + use futures::{pin_mut, FutureExt}; + use std::task::Poll; + + pub(crate) const BATTERY_LEVEL_HANDLE: Handle = Handle(0x1); + pub(crate) fn fake_battery_service(battery_level: u8) -> FakePeerService { + let mut peer_service = FakePeerService::new(); + peer_service.add_characteristic( + Characteristic { + handle: BATTERY_LEVEL_HANDLE, + uuid: BATTERY_LEVEL_UUID, + properties: CharacteristicProperties(vec![ + CharacteristicProperty::Read, + CharacteristicProperty::Notify, + ]), + permissions: AttributePermissions::default(), + descriptors: vec![], + }, + vec![battery_level], + ); + peer_service + } + + /// Builds a `BatteryMonitorClient` that is connected to a fake GATT service + /// with a Battery Service. + fn setup_client(battery_level: u8) -> (BatteryMonitorClient<FakeTypes>, FakePeerService) { + // Constructs a FakePeerService with a battery level characteristic. + let fake_peer_service = fake_battery_service(battery_level); + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + let create_result = + BatteryMonitorClient::<FakeTypes>::create(FakeClient::new(), fake_peer_service.clone()); + pin_mut!(create_result); + let polled = create_result.poll_unpin(&mut noop_cx); + let Poll::Ready(Ok(client)) = polled else { + panic!("Expected BatteryMonitorClient to be successfully created"); + }; + + (client, fake_peer_service) + } + + #[test] + fn create_client_and_read_battery_level_success() { + let battery_level = 20; + let (monitor, _fake_peer_service) = setup_client(battery_level); + assert_eq!(monitor.battery_level(), BatteryLevel(battery_level)); + } + + #[test] + fn empty_battery_service_is_error() { + let fake_peer_service = FakePeerService::new(); + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + let create_result = + BatteryMonitorClient::<FakeTypes>::create(FakeClient::new(), fake_peer_service); + pin_mut!(create_result); + let polled = create_result.poll_unpin(&mut noop_cx); + let Poll::Ready(Err(Error::Service(ServiceError::MissingCharacteristic))) = polled else { + panic!("Expected BatteryMonitorClient failure"); + }; + } + + #[test] + fn service_missing_battery_level_characteristic_is_error() { + let mut fake_peer_service = FakePeerService::new(); + // Battery Level characteristic is invalidly formatted. + fake_peer_service.add_characteristic( + Characteristic { + handle: BATTERY_LEVEL_HANDLE, + uuid: Uuid::from_u16(0x1234), // Random UUID, not Battery Level + properties: CharacteristicProperties(vec![ + CharacteristicProperty::Read, + CharacteristicProperty::Notify, + ]), + permissions: AttributePermissions::default(), + descriptors: vec![], + }, + vec![], + ); + + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + let create_result = + BatteryMonitorClient::<FakeTypes>::create(FakeClient::new(), fake_peer_service); + pin_mut!(create_result); + let polled = create_result.poll_unpin(&mut noop_cx); + let Poll::Ready(Err(Error::Service(ServiceError::MissingCharacteristic))) = polled else { + panic!("Expected BatteryMonitorClient failure"); + }; + } + + #[test] + fn invalid_battery_level_value_is_error() { + // Battery Level characteristic has an empty battery level value. + let mut fake_peer_service = FakePeerService::new(); + fake_peer_service.add_characteristic( + Characteristic { + handle: BATTERY_LEVEL_HANDLE, + uuid: BATTERY_LEVEL_UUID, + properties: CharacteristicProperties(vec![ + CharacteristicProperty::Read, + CharacteristicProperty::Notify, + ]), + permissions: AttributePermissions::default(), + descriptors: vec![], + }, + vec![], + ); + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + let create_result = + BatteryMonitorClient::<FakeTypes>::create(FakeClient::new(), fake_peer_service); + pin_mut!(create_result); + let polled = create_result.poll_unpin(&mut noop_cx); + let Poll::Ready(Err(Error::Packet(PacketError::UnexpectedDataLength))) = polled else { + panic!("Expected BatteryMonitorClient to be successfully created"); + }; + } +}
diff --git a/rust/bt-battery/src/types.rs b/rust/bt-battery/src/types.rs new file mode 100644 index 0000000..b7980c5 --- /dev/null +++ b/rust/bt-battery/src/types.rs
@@ -0,0 +1,68 @@ +// Copyright 2024 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use bt_common::packet_encoding::{Decodable, Error as PacketError}; +use bt_common::Uuid; + +/// The UUID of the GATT battery service. +/// Defined in Assigned Numbers Section 3.4.2. +pub const BATTERY_SERVICE_UUID: Uuid = Uuid::from_u16(0x180f); + +/// The UUID of the GATT Battery level characteristic. +/// Defined in Assigned Numbers Section 3.8.1. +pub const BATTERY_LEVEL_UUID: Uuid = Uuid::from_u16(0x2a19); + +pub(crate) const READ_CHARACTERISTIC_BUFFER_SIZE: usize = 255; + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub struct BatteryLevel(pub u8); + +impl Decodable for BatteryLevel { + type Error = PacketError; + + fn decode(buf: &[u8]) -> core::result::Result<(Self, usize), Self::Error> { + if buf.len() < 1 { + return Err(PacketError::UnexpectedDataLength); + } + + let level_percent = buf[0].clamp(0, 100); + Ok((BatteryLevel(level_percent), 1)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_battery_level_success() { + let buf = [55]; + let (parsed, parsed_size) = BatteryLevel::decode(&buf).expect("valid battery level"); + assert_eq!(parsed, BatteryLevel(55)); + assert_eq!(parsed_size, 1); + } + + #[test] + fn decode_large_battery_level_clamped() { + let buf = [125]; // Too large, expected to be a percentage value. + let (parsed, parsed_size) = BatteryLevel::decode(&buf).expect("valid battery level"); + assert_eq!(parsed, BatteryLevel(100)); + assert_eq!(parsed_size, 1); + } + + #[test] + fn decode_large_buf_success() { + let large_buf = [19, 0]; // Only expect a single u8 for the level. + let (parsed, parsed_size) = BatteryLevel::decode(&large_buf).expect("valid battery level"); + assert_eq!(parsed, BatteryLevel(19)); // Only the first byte should be read. + assert_eq!(parsed_size, 1); + } + + #[test] + fn decode_invalid_battery_level_buf_is_error() { + let buf = []; + let result = BatteryLevel::decode(&buf); + assert_eq!(result, Err(PacketError::UnexpectedDataLength)); + } +}