Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
115 changes: 115 additions & 0 deletions src/advertisement.rs
Original file line number Diff line number Diff line change
@@ -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<u16> {
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<u16> {
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
);
}
}
31 changes: 31 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ pub struct PeripheralProperties {
pub local_name: Option<String>,
/// The advertisement name. May be different than local_name.
pub advertisement_name: Option<String>,
/// The GAP appearance reported by this peripheral.
#[cfg_attr(feature = "serde", serde(default))]
pub appearance: Option<u16>,
/// The transmission power level for the device
pub tx_power_level: Option<i16>,
/// The most recent Received Signal Strength Indicator for the device
Expand Down Expand Up @@ -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<Vec<Self::Adapter>>;
}

#[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);
}
}
1 change: 1 addition & 0 deletions src/bluez/peripheral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/corebluetooth/peripheral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
26 changes: 26 additions & 0 deletions src/droidplug/jni/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,10 @@ impl<'a: 'b, 'b> TryFrom<JScanResult<'a, 'b>> for (BDAddr, Option<PeripheralProp
};

let rssi = Some(result.get_rssi()? as i16);
let appearance = record
.get_bytes()?
.as_deref()
.and_then(crate::advertisement::parse_appearance_from_advertisement);

let manufacturer_specific_data_array = record.get_manufacturer_specific_data()?;
let manufacturer_specific_data_obj: &JObject = &manufacturer_specific_data_array;
Expand Down Expand Up @@ -845,6 +849,7 @@ impl<'a: 'b, 'b> TryFrom<JScanResult<'a, 'b>> for (BDAddr, Option<PeripheralProp
address_type: None,
local_name: device_name.clone(),
advertisement_name: device_name,
appearance,
tx_power_level,
manufacturer_data,
service_data,
Expand All @@ -859,6 +864,7 @@ impl<'a: 'b, 'b> TryFrom<JScanResult<'a, 'b>> for (BDAddr, Option<PeripheralProp

pub struct JScanRecord<'a: 'b, 'b> {
internal: JObject<'a>,
get_bytes: JMethodID<'a>,
get_device_name: JMethodID<'a>,
get_tx_power_level: JMethodID<'a>,
get_manufacturer_specific_data: JMethodID<'a>,
Expand All @@ -885,6 +891,7 @@ impl<'a: 'b, 'b> JScanRecord<'a, 'b> {
pub fn from_env(env: &'b JNIEnv<'a>, obj: JObject<'a>) -> Result<Self> {
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(
Expand All @@ -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,
Expand All @@ -906,6 +914,24 @@ impl<'a: 'b, 'b> JScanRecord<'a, 'b> {
})
}

pub fn get_bytes(&self) -> Result<Option<Vec<u8>>> {
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<JString<'a>> {
let obj = self
.env
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 23 additions & 3 deletions src/winrtble/peripheral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ struct Shared {
address_type: RwLock<Option<AddressType>>,
local_name: RwLock<Option<String>>,
advertisement_name: RwLock<Option<String>>,
appearance: RwLock<Option<u16>>,
last_tx_power_level: RwLock<Option<i16>>, // XXX: would be nice to avoid lock here!
last_rssi: RwLock<Option<i16>>, // XXX: would be nice to avoid lock here!
latest_manufacturer_data: RwLock<HashMap<u16, Vec<u8>>>,
Expand All @@ -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()),
Expand All @@ -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(),
Expand Down Expand Up @@ -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();

Expand Down
Loading