rust/bt-csis: Implement initial client and characteristic discovery Implement the `CoordinatedSetIdentificationServiceClient` with Read-on-Create logic as per CSIS v1.1. - Add granular discovery helpers encapsulating find, read, and decode - Use higher-level `ServiceCharacteristic::read` API - Add unit tests covering success and failure modes - Update spec references to CSIS v1.1 - Remove unused type skeletons to adhere to YAGNI rule Bug: 534436497 Test: cargo test Change-Id: Id438d0a1baf718a343c2bed898da2bc4198ae55b Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3240
diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c200dde..be959b5 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml
@@ -16,6 +16,7 @@ bt-battery = { path = "bt-battery" } bt-broadcast-assistant = { path = "bt-broadcast-assistant" } bt-common = { path = "bt-common" } +bt-csis = { path = "bt-csis" } bt-gatt = { path = "bt-gatt" } bt-pacs = { path = "bt-pacs" }
diff --git a/rust/bt-csis/Cargo.toml b/rust/bt-csis/Cargo.toml new file mode 100644 index 0000000..4eeae2e --- /dev/null +++ b/rust/bt-csis/Cargo.toml
@@ -0,0 +1,15 @@ +[package] +name = "bt-csis" +version = "0.0.1" +edition.workspace = true +license.workspace = true + +[dependencies] +bt-common.workspace = true +bt-gatt.workspace = true +thiserror.workspace = true + +[dev-dependencies] +assert_matches.workspace = true +bt-gatt = { workspace = true, features = ["test-utils"] } +futures.workspace = true
diff --git a/rust/bt-csis/src/client.rs b/rust/bt-csis/src/client.rs new file mode 100644 index 0000000..f897c3f --- /dev/null +++ b/rust/bt-csis/src/client.rs
@@ -0,0 +1,549 @@ +// Copyright 2026 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 thiserror::Error; + +use bt_common::packet_encoding::Decodable; +use bt_common::Uuid; +use bt_gatt::client::{PeerService, ServiceCharacteristic}; +use bt_gatt::types::Handle; + +use crate::types::*; + +#[derive(Debug, Error)] +pub enum Error { + #[error("GATT operation error: {0}")] + Gatt(#[from] bt_gatt::types::Error), + + #[error("Packet serialization/deserialization error: {0}")] + Packet(#[from] bt_common::packet_encoding::Error), + + #[error("Missing required characteristic: {0}")] + MissingCharacteristic(Uuid), + + #[error("Extra characteristic found: {0}")] + ExtraCharacteristic(Uuid), + + #[error("Invalid characteristic value: {0}")] + InvalidCharacteristic(String), +} + +/// A GATT client for interacting with the Coordinated Set Identification +/// Service (CSIS). See CSIS v1.1 Section 2 & Section 5 and CSIP v1.1 Section +/// 4.2 & Section 4.3. +pub struct CoordinatedSetIdentificationServiceClient<T: bt_gatt::GattTypes> { + #[allow(dead_code)] + gatt_client: T::PeerService, + #[allow(dead_code)] + sirk_handle: Handle, + #[allow(dead_code)] + size_handle: Option<Handle>, + #[allow(dead_code)] + lock_handle: Option<Handle>, + #[allow(dead_code)] + rank_handle: Option<Handle>, + + // Initial read values + sirk: SetIdentityResolvingKey, + size: Option<CoordinatedSetSize>, + rank: Option<SetMemberRank>, +} + +impl<T: bt_gatt::GattTypes> CoordinatedSetIdentificationServiceClient<T> { + /// Discovers CSIS characteristics and reads initial characteristic values. + /// See CSIS v1.1 Section 5, Table 5.1 and CSIP v1.1 Section 4.3. + pub async fn create(gatt_client: T::PeerService) -> Result<Self, Error> { + let discovered = gatt_client.discover_characteristics(None).await?; + + let mut sirk_chars = Vec::new(); + let mut size_chars = Vec::new(); + let mut rank_chars = Vec::new(); + let mut lock_chars = Vec::new(); + + for ch in discovered { + let service_char = ServiceCharacteristic::new(&gatt_client, ch); + match service_char.uuid() { + SET_IDENTITY_RESOLVING_KEY_UUID => sirk_chars.push(service_char), + COORDINATED_SET_SIZE_UUID => size_chars.push(service_char), + SET_MEMBER_RANK_UUID => rank_chars.push(service_char), + SET_MEMBER_LOCK_UUID => lock_chars.push(service_char), + _ => {} + } + } + + let (sirk_handle, sirk) = Self::read_sirk(&sirk_chars).await?; + let (size_handle, size) = Self::read_size(&size_chars).await?; + let (rank_handle, rank) = Self::read_rank(&rank_chars).await?; + let lock_handle = Self::get_lock_handle(&lock_chars)?; + + if lock_handle.is_some() && rank_handle.is_none() { + // CSIS v1.1 Section 5, Table 5.1 Note C.1: Mandatory if Set Member Lock is + // supported + return Err(Error::MissingCharacteristic(SET_MEMBER_RANK_UUID)); + } + + if let (Some(r), Some(s)) = (rank, size) { + if r.0.get() > s.0.get() { + return Err(Error::InvalidCharacteristic( + "SetMemberRank cannot be greater than CoordinatedSetSize".to_string(), + )); + } + } + + Ok(Self { + gatt_client, + sirk_handle, + size_handle, + lock_handle, + rank_handle, + sirk, + size, + rank, + }) + } + + async fn read_sirk( + chars: &[ServiceCharacteristic<'_, T>], + ) -> Result<(Handle, SetIdentityResolvingKey), Error> { + if chars.is_empty() { + return Err(Error::MissingCharacteristic(SET_IDENTITY_RESOLVING_KEY_UUID)); + } + if chars.len() > 1 { + return Err(Error::ExtraCharacteristic(SET_IDENTITY_RESOLVING_KEY_UUID)); + } + let char_item = &chars[0]; + let handle = *char_item.handle(); + + let mut buf = [0; SetIdentityResolvingKey::BYTE_SIZE]; + let bytes_read = char_item.read(&mut buf).await?; + let (sirk_res, _) = SetIdentityResolvingKey::decode(&buf[..bytes_read]); + let sirk = sirk_res?; + + Ok((handle, sirk)) + } + + async fn read_size( + chars: &[ServiceCharacteristic<'_, T>], + ) -> Result<(Option<Handle>, Option<CoordinatedSetSize>), Error> { + if chars.is_empty() { + return Ok((None, None)); + } + if chars.len() > 1 { + return Err(Error::ExtraCharacteristic(COORDINATED_SET_SIZE_UUID)); + } + let char_item = &chars[0]; + let handle = *char_item.handle(); + + let mut buf = [0; CoordinatedSetSize::BYTE_SIZE]; + let bytes_read = char_item.read(&mut buf).await?; + let (size_res, _) = CoordinatedSetSize::decode(&buf[..bytes_read]); + let size = size_res?; + + Ok((Some(handle), Some(size))) + } + + async fn read_rank( + chars: &[ServiceCharacteristic<'_, T>], + ) -> Result<(Option<Handle>, Option<SetMemberRank>), Error> { + if chars.is_empty() { + return Ok((None, None)); + } + if chars.len() > 1 { + return Err(Error::ExtraCharacteristic(SET_MEMBER_RANK_UUID)); + } + let char_item = &chars[0]; + let handle = *char_item.handle(); + + let mut buf = [0; SetMemberRank::BYTE_SIZE]; + let bytes_read = char_item.read(&mut buf).await?; + let (rank_res, _) = SetMemberRank::decode(&buf[..bytes_read]); + let rank = rank_res?; + + Ok((Some(handle), Some(rank))) + } + + fn get_lock_handle(chars: &[ServiceCharacteristic<'_, T>]) -> Result<Option<Handle>, Error> { + if chars.is_empty() { + return Ok(None); + } + if chars.len() > 1 { + return Err(Error::ExtraCharacteristic(SET_MEMBER_LOCK_UUID)); + } + Ok(Some(*chars[0].handle())) + } + + pub fn sirk(&self) -> SetIdentityResolvingKey { + self.sirk + } + + pub fn size(&self) -> Option<CoordinatedSetSize> { + self.size + } + + pub fn rank(&self) -> Option<SetMemberRank> { + self.rank + } + + // TODO(b/534436497): Add lock() and unlock() methods for Exclusive + // Access & Set Member Locking. TODO(b/534436497): Add notification stream + // support for Lock and Size characteristics. +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use bt_gatt::test_utils::*; + use bt_gatt::types::{ + AttributePermissions, CharacteristicProperties, CharacteristicProperty, Handle, + }; + use bt_gatt::Characteristic; + use core::num::NonZeroU8; + use futures::{ + pin_mut, + task::{noop_waker_ref, Context, Poll}, + FutureExt, + }; + + const SIRK_HANDLE: Handle = Handle(1); + const SIZE_HANDLE: Handle = Handle(2); + const LOCK_HANDLE: Handle = Handle(3); + const RANK_HANDLE: Handle = Handle(4); + + fn add_char(service: &mut FakePeerService, handle: Handle, uuid: Uuid, value: Vec<u8>) { + service.add_characteristic( + Characteristic { + handle, + uuid, + properties: CharacteristicProperties(vec![CharacteristicProperty::Read]), + permissions: AttributePermissions::default(), + descriptors: vec![], + }, + value, + ); + } + + #[test] + fn create_success_all_chars() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + // Valid SIRK: Type 1 (Plaintext) + 16 bytes key + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x02]); + add_char(&mut service, LOCK_HANDLE, SET_MEMBER_LOCK_UUID, vec![0x01]); // 0x01 = Unlocked per CSIS v1.1 Section 5.3, Table 5.4 + add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]); + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(client) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + let client = client.unwrap(); + + assert_eq!(client.sirk_handle, SIRK_HANDLE); + assert_eq!(client.size_handle, Some(SIZE_HANDLE)); + assert_eq!(client.lock_handle, Some(LOCK_HANDLE)); + assert_eq!(client.rank_handle, Some(RANK_HANDLE)); + + assert_eq!(client.sirk().sirk_type, SirkType::Plaintext); + assert_eq!(client.size(), Some(CoordinatedSetSize(NonZeroU8::new(2).unwrap()))); + assert_eq!(client.rank(), Some(SetMemberRank(NonZeroU8::new(1).unwrap()))); + } + + #[test] + fn create_success_only_mandatory() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(client) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + let client = client.unwrap(); + + assert_eq!(client.sirk_handle, SIRK_HANDLE); + assert_eq!(client.size_handle, None); + assert_eq!(client.lock_handle, None); + assert_eq!(client.rank_handle, None); + } + + #[test] + fn create_success_rank_present_without_lock() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]); + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(client) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + let client = client.unwrap(); + + assert_eq!(client.sirk_handle, SIRK_HANDLE); + assert_eq!(client.size_handle, None); + assert_eq!(client.lock_handle, None); + assert_eq!(client.rank_handle, Some(RANK_HANDLE)); + assert_eq!(client.rank().unwrap().0.get(), 1); + } + + #[test] + fn create_fails_missing_sirk() { + let service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::MissingCharacteristic(uuid) if uuid == SET_IDENTITY_RESOLVING_KEY_UUID); + } + + #[test] + fn create_fails_duplicate_sirk() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val.clone()); + add_char(&mut service, Handle(5), SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == SET_IDENTITY_RESOLVING_KEY_UUID); + } + + #[test] + fn create_fails_lock_present_rank_missing() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + add_char(&mut service, LOCK_HANDLE, SET_MEMBER_LOCK_UUID, vec![0x01]); + // Missing Rank + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::MissingCharacteristic(uuid) if uuid == SET_MEMBER_RANK_UUID); + } + + #[test] + fn create_fails_invalid_size() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x00]); // Invalid Size 0 + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::Packet(_)); + } + + #[test] + fn create_fails_invalid_rank_zero() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x00]); // Invalid Rank 0 + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::Packet(_)); + } + + #[test] + fn create_fails_rank_greater_than_size() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x02]); + add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x03]); // Rank 3 > Size 2 + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::InvalidCharacteristic(_)); + } + + #[test] + fn create_fails_duplicate_size() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x02]); + add_char(&mut service, Handle(5), COORDINATED_SET_SIZE_UUID, vec![0x03]); // Duplicate + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == COORDINATED_SET_SIZE_UUID); + } + + #[test] + fn create_fails_duplicate_lock() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + add_char(&mut service, LOCK_HANDLE, SET_MEMBER_LOCK_UUID, vec![0x01]); + add_char(&mut service, Handle(5), SET_MEMBER_LOCK_UUID, vec![0x01]); // Duplicate + add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]); // Rank is mandatory if lock present per CSIS v1.1 Table 5.1 Note C.1 + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == SET_MEMBER_LOCK_UUID); + } + + #[test] + fn create_fails_duplicate_rank() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + + add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]); + add_char(&mut service, Handle(5), SET_MEMBER_RANK_UUID, vec![0x02]); // Duplicate + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == SET_MEMBER_RANK_UUID); + } + + #[test] + fn create_fails_sirk_invalid_length() { + let invalid_payloads = vec![ + vec![], // Empty (0 bytes) + vec![0x01; 5], // Too short (5 bytes < 17 bytes) + ]; + + for payload in invalid_payloads { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, payload); + + let create_fut = + CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!( + result.err().unwrap(), + Error::Packet(bt_common::packet_encoding::Error::UnexpectedDataLength) + ); + } + } + + #[test] + fn create_fails_size_invalid_length() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![]); // Empty (0 bytes) + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!( + result.err().unwrap(), + Error::Packet(bt_common::packet_encoding::Error::UnexpectedDataLength) + ); + } + + #[test] + fn create_fails_rank_invalid_length() { + let mut service = FakePeerService::new(); + let mut noop_cx = Context::from_waker(noop_waker_ref()); + + let mut sirk_val = vec![0x01]; + sirk_val.extend_from_slice(&[0xAB; 16]); + add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val); + add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![]); // Empty (0 bytes) + + let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service); + pin_mut!(create_fut); + let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else { + panic!("Expected create to be ready"); + }; + assert!(result.is_err()); + assert_matches!( + result.err().unwrap(), + Error::Packet(bt_common::packet_encoding::Error::UnexpectedDataLength) + ); + } +}
diff --git a/rust/bt-csis/src/lib.rs b/rust/bt-csis/src/lib.rs new file mode 100644 index 0000000..f5ee452 --- /dev/null +++ b/rust/bt-csis/src/lib.rs
@@ -0,0 +1,6 @@ +// Copyright 2026 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. + +pub mod client; +pub mod types;
diff --git a/rust/bt-csis/src/types.rs b/rust/bt-csis/src/types.rs new file mode 100644 index 0000000..a96ee7d --- /dev/null +++ b/rust/bt-csis/src/types.rs
@@ -0,0 +1,250 @@ +// Copyright 2026 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 core::num::NonZeroU8; + +use bt_common::packet_encoding::{Decodable, Encodable, Error as PacketError}; +use bt_common::{decodable_enum, Uuid}; + +/// 16-bit UUID value for the Coordinated Set Identification Service and its +/// characteristics. +pub const COORDINATED_SET_IDENTIFICATION_SERVICE_UUID: Uuid = Uuid::from_u16(0x1846); +pub const SET_IDENTITY_RESOLVING_KEY_UUID: Uuid = Uuid::from_u16(0x2B84); +pub const COORDINATED_SET_SIZE_UUID: Uuid = Uuid::from_u16(0x2B85); +pub const SET_MEMBER_LOCK_UUID: Uuid = Uuid::from_u16(0x2B86); +pub const SET_MEMBER_RANK_UUID: Uuid = Uuid::from_u16(0x2B87); + +// TODO(b/534436497): Add Coordinated Set Name characteristic (CSIS v1.1 Section +// 5.5). +// TODO(b/534436497): Add SetMemberLock enum and CsisApplicationError +// enum. + +decodable_enum! { + /// The type of the Set Identity Resolving Key (SIRK). + /// See CSIS v1.1 Section 5.1.1, Table 5.2. + pub enum SirkType<u8, bt_common::packet_encoding::Error, OutOfRange> { + Encrypted = 0x00, + Plaintext = 0x01, + } +} + +/// The Set Identity Resolving Key (SIRK) characteristic exposes the key +/// associated with the Coordinated Set (1 octet Type + 16 octets Value). +/// See CSIS v1.1 Section 5.1. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SetIdentityResolvingKey { + pub sirk_type: SirkType, + pub value: [u8; 16], +} + +impl SetIdentityResolvingKey { + pub const BYTE_SIZE: usize = 17; +} + +impl Decodable for SetIdentityResolvingKey { + type Error = PacketError; + + fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) { + if buf.len() < Self::BYTE_SIZE { + return (Err(PacketError::UnexpectedDataLength), buf.len()); + } + let sirk_type = match SirkType::try_from(buf[0]) { + Ok(t) => t, + Err(e) => return (Err(e), Self::BYTE_SIZE), + }; + let mut value = [0; 16]; + value.copy_from_slice(&buf[1..17]); + + (Ok(Self { sirk_type, value }), Self::BYTE_SIZE) + } +} + +impl Encodable for SetIdentityResolvingKey { + type Error = PacketError; + + fn encoded_len(&self) -> usize { + Self::BYTE_SIZE + } + + fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> { + if buf.len() < Self::BYTE_SIZE { + return Err(PacketError::BufferTooSmall); + } + buf[0] = self.sirk_type.into(); + buf[1..17].copy_from_slice(&self.value); + Ok(()) + } +} + +/// The Set Member Rank characteristic exposes a numeric value that is unique +/// within a Coordinated Set (0x01 to set size). See CSIS v1.1 Section 5.4. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SetMemberRank(pub NonZeroU8); + +impl SetMemberRank { + pub const BYTE_SIZE: usize = 1; +} + +impl Decodable for SetMemberRank { + type Error = PacketError; + + fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) { + if buf.is_empty() { + return (Err(PacketError::UnexpectedDataLength), 0); + } + let val = match NonZeroU8::new(buf[0]) { + Some(v) => v, + None => { + return ( + Err(PacketError::InvalidParameter("SetMemberRank cannot be 0".to_string())), + 1, + ) + } + }; + (Ok(Self(val)), Self::BYTE_SIZE) + } +} + +impl Encodable for SetMemberRank { + type Error = PacketError; + + fn encoded_len(&self) -> usize { + Self::BYTE_SIZE + } + + fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> { + if buf.is_empty() { + return Err(PacketError::BufferTooSmall); + } + buf[0] = self.0.get(); + Ok(()) + } +} + +/// The Coordinated Set Size characteristic exposes the number of devices +/// comprising the Coordinated Set (0x01 to 0xFF). See CSIS v1.1 Section 5.2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct CoordinatedSetSize(pub NonZeroU8); + +impl CoordinatedSetSize { + pub const BYTE_SIZE: usize = 1; +} + +impl Decodable for CoordinatedSetSize { + type Error = PacketError; + + fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) { + if buf.is_empty() { + return (Err(PacketError::UnexpectedDataLength), 0); + } + let val = match NonZeroU8::new(buf[0]) { + Some(v) => v, + None => { + return ( + Err(PacketError::InvalidParameter( + "CoordinatedSetSize cannot be 0".to_string(), + )), + 1, + ); + } + }; + (Ok(Self(val)), Self::BYTE_SIZE) + } +} + +impl Encodable for CoordinatedSetSize { + type Error = PacketError; + + fn encoded_len(&self) -> usize { + Self::BYTE_SIZE + } + + fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> { + if buf.is_empty() { + return Err(PacketError::BufferTooSmall); + } + buf[0] = self.0.get(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + #[test] + fn sirk_decoding() { + let mut buf = [0; 17]; + buf[0] = 0x01; // Plaintext + buf[1..17].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + + let (res, consumed) = SetIdentityResolvingKey::decode(&buf); + assert_eq!(consumed, 17); + let sirk = res.unwrap(); + assert_eq!(sirk.sirk_type, SirkType::Plaintext); + assert_eq!(sirk.value, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + } + + #[test] + fn sirk_encoding() { + let sirk = SetIdentityResolvingKey { sirk_type: SirkType::Encrypted, value: [1; 16] }; + let mut buf = [0; 17]; + sirk.encode(&mut buf).unwrap(); + assert_eq!(buf[0], 0x00); + assert_eq!(buf[1..17], [1; 16]); + } + + #[test] + fn set_member_rank_decoding() { + let mut buf = [0; 1]; + buf[0] = 0x05; + let (res, consumed) = SetMemberRank::decode(&buf); + assert_eq!(consumed, 1); + assert_eq!(res.unwrap().0.get(), 5); + + buf[0] = 0x00; // Invalid + let (res, _) = SetMemberRank::decode(&buf); + assert_matches!(res, Err(PacketError::InvalidParameter(_))); + } + + #[test] + fn set_member_rank_encoding() { + let rank = SetMemberRank(NonZeroU8::new(5).unwrap()); + assert_eq!(rank.encoded_len(), 1); + + let mut buf = [0; 1]; + rank.encode(&mut buf).unwrap(); + assert_eq!(buf[0], 0x05); + + let mut empty_buf = []; + assert_matches!(rank.encode(&mut empty_buf), Err(PacketError::BufferTooSmall)); + } + + #[test] + fn coordinated_set_size_decoding() { + let mut buf = [0; 1]; + buf[0] = 0x02; + let (res, consumed) = CoordinatedSetSize::decode(&buf); + assert_eq!(consumed, 1); + assert_eq!(res.unwrap().0.get(), 2); + + buf[0] = 0x00; // Invalid + let (res, _) = CoordinatedSetSize::decode(&buf); + assert_matches!(res, Err(PacketError::InvalidParameter(_))); + } + + #[test] + fn coordinated_set_size_encoding() { + let size = CoordinatedSetSize(NonZeroU8::new(2).unwrap()); + assert_eq!(size.encoded_len(), 1); + + let mut buf = [0; 1]; + size.encode(&mut buf).unwrap(); + assert_eq!(buf[0], 0x02); + + let mut empty_buf = []; + assert_matches!(size.encode(&mut empty_buf), Err(PacketError::BufferTooSmall)); + } +}
diff --git a/rust/bt-gatt/src/client.rs b/rust/bt-gatt/src/client.rs index 4f2ffb1..fcaf8a0 100644 --- a/rust/bt-gatt/src/client.rs +++ b/rust/bt-gatt/src/client.rs
@@ -143,6 +143,11 @@ } impl<'a, T: crate::GattTypes> ServiceCharacteristic<'a, T> { + pub fn new(service: &'a T::PeerService, characteristic: Characteristic) -> Self { + let uuid = characteristic.uuid; + Self { service, characteristic, uuid } + } + pub async fn find( service: &'a T::PeerService, uuid: Uuid,
diff --git a/rust/bt-gatt/src/test_utils.rs b/rust/bt-gatt/src/test_utils.rs index a5bee21..7f274f2 100644 --- a/rust/bt-gatt/src/test_utils.rs +++ b/rust/bt-gatt/src/test_utils.rs
@@ -114,8 +114,9 @@ let Some((_, value)) = read_characteristics.get(handle) else { return ready(Err(Error::Gatt(GattError::InvalidHandle))); }; - buf[..value.len()].copy_from_slice(value.as_slice()); - ready(Ok((value.len(), false))) + let len = std::cmp::min(buf.len(), value.len()); + buf[..len].copy_from_slice(&value[..len]); + ready(Ok((len, false))) } // For testing, should call `expect_characteristic_value` with the expected