diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5b455f..f2e26ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# Unreleased + +## Features + +- Add `appearance` to `PeripheralProperties`, populated from GAP Appearance + advertising data on Windows, Linux, and Android. CoreBluetooth does not expose + this advertising field, so it remains `None` on Apple platforms. + +## Breaking Changes + +- **`PeripheralProperties` struct literals**: The new `appearance` field must be + initialized by callers that construct this public struct directly. + # 0.12.0 (2026-03-08) ## Features diff --git a/src/advertisement.rs b/src/advertisement.rs new file mode 100644 index 00000000..d2fb3612 --- /dev/null +++ b/src/advertisement.rs @@ -0,0 +1,115 @@ +// btleplug Source Code File +// +// Copyright 2020 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +/// GAP Appearance advertising data type. +pub(crate) const APPEARANCE_DATA_TYPE: u8 = 0x19; + +/// Parse the payload of a GAP Appearance advertising data section. +pub(crate) fn parse_appearance(data: &[u8]) -> Option { + let bytes: [u8; 2] = data.try_into().ok()?; + Some(u16::from_le_bytes(bytes)) +} + +/// Parse GAP Appearance from a length-prefixed Bluetooth LE advertising record. +#[cfg(any(target_os = "android", test))] +pub(crate) fn parse_appearance_from_advertisement(data: &[u8]) -> Option { + let mut offset = 0; + + while let Some(&length) = data.get(offset) { + offset += 1; + + if length == 0 { + break; + } + + let end = offset.checked_add(usize::from(length))?; + let section = data.get(offset..end)?; + let (&data_type, payload) = section.split_first()?; + + if data_type == APPEARANCE_DATA_TYPE { + if let Some(appearance) = parse_appearance(payload) { + return Some(appearance); + } + } + + offset = end; + } + + None +} + +#[cfg(test)] +mod tests { + use super::{APPEARANCE_DATA_TYPE, parse_appearance, parse_appearance_from_advertisement}; + + #[test] + fn parses_appearance_payload_as_little_endian() { + assert_eq!(parse_appearance(&[0x80, 0x04]), Some(0x0480)); + assert_eq!(parse_appearance(&[0x00, 0x00]), Some(0x0000)); + } + + #[test] + fn rejects_appearance_payloads_that_are_not_exactly_two_bytes() { + assert_eq!(parse_appearance(&[]), None); + assert_eq!(parse_appearance(&[0x80]), None); + assert_eq!(parse_appearance(&[0x80, 0x04, 0x00]), None); + } + + #[test] + fn finds_appearance_among_other_advertising_sections() { + let advertisement = [ + 2, + 0x01, + 0x06, + 3, + APPEARANCE_DATA_TYPE, + 0x80, + 0x04, + 2, + 0x0a, + 0xf8, + ]; + + assert_eq!( + parse_appearance_from_advertisement(&advertisement), + Some(0x0480) + ); + } + + #[test] + fn skips_invalid_appearance_section_and_accepts_a_later_valid_one() { + let advertisement = [ + 2, + APPEARANCE_DATA_TYPE, + 0x80, + 3, + APPEARANCE_DATA_TYPE, + 0x40, + 0x03, + ]; + + assert_eq!( + parse_appearance_from_advertisement(&advertisement), + Some(0x0340) + ); + } + + #[test] + fn handles_missing_and_malformed_advertising_sections() { + assert_eq!(parse_appearance_from_advertisement(&[]), None); + assert_eq!(parse_appearance_from_advertisement(&[0]), None); + assert_eq!(parse_appearance_from_advertisement(&[2, 0x01, 0x06]), None); + assert_eq!( + parse_appearance_from_advertisement(&[3, APPEARANCE_DATA_TYPE, 0x80,]), + None + ); + assert_eq!( + parse_appearance_from_advertisement(&[0, 3, APPEARANCE_DATA_TYPE, 0x80, 0x04,]), + None + ); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 5986714e..12eb0a35 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -189,6 +189,9 @@ pub struct PeripheralProperties { pub local_name: Option, /// The advertisement name. May be different than local_name. pub advertisement_name: Option, + /// The GAP appearance reported by this peripheral. + #[cfg_attr(feature = "serde", serde(default))] + pub appearance: Option, /// The transmission power level for the device pub tx_power_level: Option, /// The most recent Received Signal Strength Indicator for the device @@ -504,3 +507,31 @@ pub trait Manager { /// Get a list of all Bluetooth adapters on the system. Each adapter implements [`Central`]. async fn adapters(&self) -> Result>; } + +#[cfg(all(test, feature = "serde"))] +mod tests { + use super::PeripheralProperties; + + #[test] + fn peripheral_properties_round_trip_appearance() { + let properties = PeripheralProperties { + appearance: Some(0x0340), + ..PeripheralProperties::default() + }; + + let value = serde_json::to_value(&properties).unwrap(); + assert_eq!(value["appearance"], 0x0340); + + let decoded: PeripheralProperties = serde_json::from_value(value).unwrap(); + assert_eq!(decoded.appearance, Some(0x0340)); + } + + #[test] + fn peripheral_properties_missing_appearance_defaults_to_none() { + let mut value = serde_json::to_value(PeripheralProperties::default()).unwrap(); + value.as_object_mut().unwrap().remove("appearance").unwrap(); + + let properties: PeripheralProperties = serde_json::from_value(value).unwrap(); + assert_eq!(properties.appearance, None); + } +} diff --git a/src/bluez/peripheral.rs b/src/bluez/peripheral.rs index 2310aa1b..e2d23d4e 100644 --- a/src/bluez/peripheral.rs +++ b/src/bluez/peripheral.rs @@ -156,6 +156,7 @@ impl api::Peripheral for Peripheral { address_type: Some(device_info.address_type.into()), local_name: device_info.alias.or(device_info.name.clone()), advertisement_name: device_info.name, + appearance: device_info.appearance, tx_power_level: device_info.tx_power, rssi: device_info.rssi, manufacturer_data: device_info.manufacturer_data, diff --git a/src/corebluetooth/peripheral.rs b/src/corebluetooth/peripheral.rs index 229daaa6..e2f396b8 100644 --- a/src/corebluetooth/peripheral.rs +++ b/src/corebluetooth/peripheral.rs @@ -100,6 +100,7 @@ impl Peripheral { address_type: None, local_name, advertisement_name, + appearance: None, tx_power_level: None, rssi: None, manufacturer_data: HashMap::new(), diff --git a/src/droidplug/jni/objects.rs b/src/droidplug/jni/objects.rs index 6e5e051a..33ef0d38 100644 --- a/src/droidplug/jni/objects.rs +++ b/src/droidplug/jni/objects.rs @@ -786,6 +786,10 @@ impl<'a: 'b, 'b> TryFrom> for (BDAddr, Option TryFrom> for (BDAddr, Option TryFrom> for (BDAddr, Option { internal: JObject<'a>, + get_bytes: JMethodID<'a>, get_device_name: JMethodID<'a>, get_tx_power_level: JMethodID<'a>, get_manufacturer_specific_data: JMethodID<'a>, @@ -885,6 +891,7 @@ impl<'a: 'b, 'b> JScanRecord<'a, 'b> { pub fn from_env(env: &'b JNIEnv<'a>, obj: JObject<'a>) -> Result { let class = env.auto_local(env.find_class("android/bluetooth/le/ScanRecord")?); + let get_bytes = env.get_method_id(&class, "getBytes", "()[B")?; let get_device_name = env.get_method_id(&class, "getDeviceName", "()Ljava/lang/String;")?; let get_tx_power_level = env.get_method_id(&class, "getTxPowerLevel", "()I")?; let get_manufacturer_specific_data = env.get_method_id( @@ -897,6 +904,7 @@ impl<'a: 'b, 'b> JScanRecord<'a, 'b> { env.get_method_id(&class, "getServiceUuids", "()Ljava/util/List;")?; Ok(Self { internal: obj, + get_bytes, get_device_name, get_tx_power_level, get_manufacturer_specific_data, @@ -906,6 +914,24 @@ impl<'a: 'b, 'b> JScanRecord<'a, 'b> { }) } + pub fn get_bytes(&self) -> Result>> { + let value = self + .env + .call_method_unchecked( + self.internal, + self.get_bytes, + JavaType::Array(JavaType::Primitive(Primitive::Byte).into()), + &[], + )? + .l()?; + if self.env.is_same_object(value.clone(), JObject::null())? { + Ok(None) + } else { + crate::droidplug::jni_utils::arrays::byte_array_to_vec(self.env, value.into_inner()) + .map(Some) + } + } + pub fn get_device_name(&self) -> Result> { let obj = self .env diff --git a/src/lib.rs b/src/lib.rs index e2181590..227b0bcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,6 +89,8 @@ use crate::api::ParseBDAddrError; use std::result; use std::time::Duration; +#[cfg(any(target_os = "android", target_os = "windows", test))] +mod advertisement; pub mod api; #[cfg(target_os = "linux")] mod bluez; diff --git a/src/winrtble/peripheral.rs b/src/winrtble/peripheral.rs index 7924d678..0b783164 100644 --- a/src/winrtble/peripheral.rs +++ b/src/winrtble/peripheral.rs @@ -82,6 +82,7 @@ struct Shared { address_type: RwLock>, local_name: RwLock>, advertisement_name: RwLock>, + appearance: RwLock>, last_tx_power_level: RwLock>, // XXX: would be nice to avoid lock here! last_rssi: RwLock>, // XXX: would be nice to avoid lock here! latest_manufacturer_data: RwLock>>, @@ -105,6 +106,7 @@ impl Peripheral { address_type: RwLock::new(None), local_name: RwLock::new(None), advertisement_name: RwLock::new(None), + appearance: RwLock::new(None), last_tx_power_level: RwLock::new(None), last_rssi: RwLock::new(None), latest_manufacturer_data: RwLock::new(HashMap::new()), @@ -123,6 +125,7 @@ impl Peripheral { address_type: *self.shared.address_type.read().unwrap(), local_name: self.shared.local_name.read().unwrap().clone(), advertisement_name: self.shared.advertisement_name.read().unwrap().clone(), + appearance: *self.shared.appearance.read().unwrap(), tx_power_level: *self.shared.last_tx_power_level.read().unwrap(), rssi: *self.shared.last_rssi.read().unwrap(), manufacturer_data: self.shared.latest_manufacturer_data.read().unwrap().clone(), @@ -183,19 +186,36 @@ impl Peripheral { // The Windows Runtime API (as of 19041) does not directly expose Service Data as a friendly API (like Manufacturer Data above) // Instead they provide data sections for access to raw advertising data. That is processed here. if let Ok(data_sections) = advertisement.DataSections() { - // See if we have any advertised service data before taking a lock to update... let mut found_service_data = false; + let mut appearance = None; for section in &data_sections { - match section.DataType().unwrap() { + let Ok(data_type) = section.DataType() else { + continue; + }; + match data_type { advertisement_data_type::SERVICE_DATA_16_BIT_UUID | advertisement_data_type::SERVICE_DATA_32_BIT_UUID | advertisement_data_type::SERVICE_DATA_128_BIT_UUID => { found_service_data = true; - break; + } + crate::advertisement::APPEARANCE_DATA_TYPE => { + if let Ok(data) = section.Data() { + if let Some(parsed) = + crate::advertisement::parse_appearance(&utils::to_vec(&data)) + { + appearance = Some(parsed); + } + } } _ => {} } } + + if let Some(appearance) = appearance { + *self.shared.appearance.write().unwrap() = Some(appearance); + } + + // See if we have any advertised service data before taking a lock to update... if found_service_data { let mut service_data_guard = self.shared.latest_service_data.write().unwrap();