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));
+ }
+}