loading...

Using rust, blurz to read from a BLE device

lcsfelix profile image lcsfelix ・9 min read

After buying some cheap Xiaomi hygrometer-thermometers off AliExpress and learning they have Bluetooth connectivity, I decided to see if I could read the temperature and humidity values without the companion app.

Xiaomi Mijia 2 Hygrometer-Thermometer

Xiaomi Mijia 2 Hygrometer-Thermometer

Project structure

├── Cargo.lock
├── Cargo.toml
├── src
│   ├── explore_device.rs
│   └── main.rs

Adding dependencies

Assuming you have rust installed and ready to go (if not, go here), generate a new project and add blurz as a dependency. We'll also be using regex and lazy_static (see this).

# Cargo.toml
[dependencies]
blurz = "0.4.0"
regex = "1"
lazy_static = "1.4.0"

NOTE: blurz depends on dbus, so you may need to install some
libdbus dev packages.
As I write this, the crate page has instructions for Ubuntu. For me, on Fedora 32, that just meant dnf install dbus-devel.

Finding devices

Then, the first thing to do is to look for devices.

# src/main.rs
let bt_session = &BluetoothSession::create_session(None).unwrap();
let adapter: BluetoothAdapter = BluetoothAdapter::init(bt_session).unwrap();
let adapter_id = adapter.get_id();
let discover_session = BluetoothDiscoverySession::create_session(&bt_session, adapter_id).unwrap();
discover_session.start_discovery().unwrap();
let device_list = adapter.get_device_list().unwrap();
discover_session.stop_discovery().unwrap();
# device_list
[
    "/org/bluez/hci0/dev_A4_C1_38_15_03_55",
    "/org/bluez/hci0/dev_A4_C1_38_64_7E_DB",
    "/org/bluez/hci0/dev_A4_C1_38_F3_C9_A9"
]

get_device_list gives us the object_path of each device in range. It's probably helpful to learn the names of the devices.

# src/main.rs
for device_path in device_list {
    let device = BluetoothDevice::new(bt_session, device_path.to_string());
    println!("Device: {:?} Name: {:?}", device_path, device.get_name().ok());
}
# Terminal output
Device: "/org/bluez/hci0/dev_A4_C1_38_15_03_55" Name: "LYWSD03MMC"
Device: "/org/bluez/hci0/dev_A4_C1_38_64_7E_DB" Name: "LYWSD03MMC"
Device: "/org/bluez/hci0/dev_A4_C1_38_F3_C9_A9" Name: "LYWSD03MMC"

I have three devices, but let's focus on just one of them.

# src/main.rs
let device = BluetoothDevice::new(bt_session, String::from("/org/bluez/hci0/dev_A4_C1_38_15_03_55"));

GATT Services

These are BLE devices. After we connect to them, we can explore the GATT profile and learn what information is available.
GATT Profile

GATT Profile diagram
Connecting to a device
# src/main.rs
if let Err(e) = device.connect(10000) {
    println!("Failed to connect {:?}: {:?}", device.get_id(), e);
} else {
    // We need to wait a bit after calling connect to safely
    // get the gatt services
    thread::sleep(Duration::from_millis(5000));
    // Interact with device
    device.disconnect().unwrap();
}
Listing Services
# src/explore_device.rs
const UUID_REGEX: &str = r"([0-9a-f]{8})-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}";

let services_list = match device.get_gatt_services().unwrap()

lazy_static! {
  static ref RE: Regex = Regex::new(UUID_REGEX).unwrap();
}

for service_path in services_list {
    let service = BluetoothGATTService::new(session, service_path.to_string());
    let uuid = service.get_uuid().unwrap();
    let assigned_number = RE.captures(&uuid).unwrap().get(1).map_or("", |m| m.as_str());

    println!("Service UUID: {:?} Assigned Number: 0x{:?}", uuid, assigned_number);
}

A UUID is a 128-bit value. To reduce the burden of storing and transferring 128-bit UUID values, a range of UUID values has been pre-allocated for assignment to often-used, registered purposes. UUID values in the pre-allocated range have aliases that are represented as 16-bit or 32-bit values.
in Bluetooth Core Specification

In this case they appear to be 16-bit values. I removed trailing zeros for readability. Aything starting with 0x18XX is defined in the specification, the others were probably added by the manufacturer.

# Terminal output
Service UUID: "00000100-0065-6c62-2e74-6f696d2e696d" Assigned Number: 0x"0100"
Service UUID: "0000fe95-0000-1000-8000-00805f9b34fb" Assigned Number: 0x"fe95"
Service UUID: "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccb0"
Service UUID: "00010203-0405-0607-0809-0a0b0c0d1912" Assigned Number: 0x"00010203"
Service UUID: "0000180f-0000-1000-8000-00805f9b34fb" Assigned Number: 0x"180f"
Service UUID: "0000180a-0000-1000-8000-00805f9b34fb" Assigned Number: 0x"180a"
Service UUID: "00001801-0000-1000-8000-00805f9b34fb" Assigned Number: 0x"1801"
Service UUID: "00001800-0000-1000-8000-00805f9b34fb" Assigned Number: 0x"1800"
Listing characteristics

Now that we have a list of services, we can check what characteristics they have. Note, I'll be focusing on Service 0xebe0ccb0 since I already know it contains the information I'm looking for.

# src/explore_device.rs
let characteristics = service.get_gatt_characteristics().unwrap();
for characteristic_path in characteristics {
    let characteristic = BluetoothGATTCharacteristic::new(session, characteristic_path);
    let uuid = characteristic.get_uuid().unwrap();
    let assigned_number = RE.captures(&uuid).unwrap().get(1).map_or("", |m| m.as_str());
    let flags = characteristic.get_flags().unwrap();

    println!(" Characteristic Assigned Number: 0x{:?} Flags: {:?}", assigned_number, flags);
}
# Terminal output
Characteristic UUID: "ebe0ccd9-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccd9" Flags: ["write", "notify"]
Characteristic UUID: "ebe0ccd8-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccd8" Flags: ["write"]
Characteristic UUID: "ebe0ccd7-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccd7" Flags: ["read", "write"]
Characteristic UUID: "ebe0ccd1-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccd1" Flags: ["write"]
Characteristic UUID: "ebe0ccc8-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccc8" Flags: ["write"]
Characteristic UUID: "ebe0ccc4-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccc4" Flags: ["read"]
Characteristic UUID: "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccc1" Flags: ["read", "notify"]
Characteristic UUID: "ebe0ccbe-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccbe" Flags: ["read", "write"]
Characteristic UUID: "ebe0ccbc-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccbc" Flags: ["notify"]
Characteristic UUID: "ebe0ccbb-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccbb" Flags: ["read"]
Characteristic UUID: "ebe0ccba-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccba" Flags: ["read", "write"]
Characteristic UUID: "ebe0ccb9-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccb9" Flags: ["read"]
Characteristic UUID: "ebe0ccb7-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccb7" Flags: ["read", "write"]

Now... This doesn't look very useful, does it? Unless we're the manufacturer, we have no idea what any of this means. That is where descriptors come in handy!

Listing descriptors
# src/explore_device.rs
let descriptors = characteristic.get_gatt_descriptors().unwrap();
for descriptor_path in descriptors {
    let descriptor = BluetoothGATTDescriptor::new(session, descriptor_path);
    let uuid = descriptor.get_uuid().unwrap();
    let assigned_number = RE.captures(&uuid).unwrap().get(1).map_or("", |m| m.as_str());
    let value = descriptor.read_value(None).unwrap();

    println!("    Descriptor Assigned Number: 0x{:?} Read Value: {:?}", assigned_number, value);
}
# Terminal output
Characteristic Assigned Number: 0x"ebe0ccd9" Flags: ["write", "notify"]
  Descriptor Assigned Number: 0x"00002902" Read Value: "[0, 0]"
  Descriptor Assigned Number: 0x"00002901" Read Value: "[112, 97, 114, 97, 95, 118, 97, 108, 117, 101, 95, 103, 101, 116, 0]"
Characteristic Assigned Number: 0x"ebe0ccd8" Flags: ["write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[115, 101, 116, 32, 99, 111, 110, 110, 32, 105, 110, 116, 101, 114, 118, 97, 108, 0]"
Characteristic Assigned Number: 0x"ebe0ccd7" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[99, 111, 109, 102, 111, 114, 116, 97, 98, 108, 101, 32, 116, 101, 109, 112, 32, 97, 110, 100, 32, 104, 117, 109, 105, 0]"
Characteristic Assigned Number: 0x"ebe0ccd1" Flags: ["write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[99, 108, 101, 97, 114, 32, 100, 97, 116, 97, 0]"
Characteristic Assigned Number: 0x"ebe0ccc8" Flags: ["write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[100, 105, 115, 99, 111, 110, 110, 101, 99, 116, 0]"
Characteristic Assigned Number: 0x"ebe0ccc4" Flags: ["read"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[66, 97, 116, 116, 0]"
Characteristic Assigned Number: 0x"ebe0ccc1" Flags: ["read", "notify"]
  Descriptor Assigned Number: 0x"00002902" Read Value: "[0, 0]"
  Descriptor Assigned Number: 0x"00002901" Read Value: "[84, 101, 109, 112, 101, 114, 97, 116, 117, 114, 101, 32, 97, 110, 100, 32, 72, 117, 109, 105, 100, 105, 116, 121, 0]"
Characteristic Assigned Number: 0x"ebe0ccbe" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[84, 101, 109, 112, 101, 114, 97, 116, 117, 114, 101, 32, 85, 105, 110, 116, 0]"
Characteristic Assigned Number: 0x"ebe0ccbc" Flags: ["notify"]
  Descriptor Assigned Number: 0x"00002902" Read Value: "[0, 0]"
  Descriptor Assigned Number: 0x"00002901" Read Value: "[68, 97, 116, 97, 32, 78, 111, 116, 105, 102, 121, 0]"
Characteristic Assigned Number: 0x"ebe0ccbb" Flags: ["read"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[68, 97, 116, 97, 32, 82, 101, 97, 100, 0]"
Characteristic Assigned Number: 0x"ebe0ccba" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[73, 110, 100, 101, 120, 0]"
Characteristic Assigned Number: 0x"ebe0ccb9" Flags: ["read"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[68, 97, 116, 97, 32, 67, 111, 117, 110, 116, 0]"
Characteristic Assigned Number: 0x"ebe0ccb7" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "[84, 105, 109, 101, 0]"

Well, that certainly looks promising, but not yet useful, right? Luckily, descriptors are well defined so we can check the specification to help us make sense of this.
0x2902, or Client Characteristic Configuration, describes whether the device has Notifications or Indications enabled or disabled. I found this to be a good summary on that subject. That still doesn't tells us what this characteristic is, though.
0x2901, however, is named Characteristic User Description and is formated as an utf-8. Hurray! Let's turn those Vec<u8> into readable strings then.

# src/explore_device.rs
let value = match &assigned_number[4..] {
  "2901" => str::from_utf8(&value).unwrap().to_string(),
  _ => format!("{:?}", value)
};

println!("    Descriptor Assigned Number: 0x{:?} Read Value: {:?}", assigned_number, value);
# Terminal output
Service UUID: "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6" Assigned Number: 0x"ebe0ccb0"
Characteristic Assigned Number: 0x"ebe0ccd9" Flags: ["write", "notify"]
  Descriptor Assigned Number: 0x"00002902" Read Value: "[0, 0]"
  Descriptor Assigned Number: 0x"00002901" Read Value: "para_value_get\u{0}"
Characteristic Assigned Number: 0x"ebe0ccd8" Flags: ["write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "set conn interval\u{0}"
Characteristic Assigned Number: 0x"ebe0ccd7" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "comfortable temp and humi\u{0}"
Characteristic Assigned Number: 0x"ebe0ccd1" Flags: ["write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "clear data\u{0}"
Characteristic Assigned Number: 0x"ebe0ccc8" Flags: ["write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "disconnect\u{0}"
Characteristic Assigned Number: 0x"ebe0ccc4" Flags: ["read"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "Batt\u{0}"
Characteristic Assigned Number: 0x"ebe0ccc1" Flags: ["read", "notify"]
  Descriptor Assigned Number: 0x"00002902" Read Value: "[0, 0]"
  Descriptor Assigned Number: 0x"00002901" Read Value: "Temperature and Humidity\u{0}"
Characteristic Assigned Number: 0x"ebe0ccbe" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "Temperature Uint\u{0}"
Characteristic Assigned Number: 0x"ebe0ccbc" Flags: ["notify"]
  Descriptor Assigned Number: 0x"00002902" Read Value: "[0, 0]"
  Descriptor Assigned Number: 0x"00002901" Read Value: "Data Notify\u{0}"
Characteristic Assigned Number: 0x"ebe0ccbb" Flags: ["read"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "Data Read\u{0}"
Characteristic Assigned Number: 0x"ebe0ccba" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "Index\u{0}"
Characteristic Assigned Number: 0x"ebe0ccb9" Flags: ["read"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "Data Count\u{0}"
Characteristic Assigned Number: 0x"ebe0ccb7" Flags: ["read", "write"]
  Descriptor Assigned Number: 0x"00002901" Read Value: "Time\u{0}"

That's more like it! Now we know which of these
characteristics (0xebe0ccc1, with the description Temperature and Humidity) carries Temperature and Humidity values. Now, notice how it has the notify flag. We need to tell the device we're interested in being notified.

Notifications and Bluetooth Events

We're only focusing on one characteristic, so we'll call start_notify on it.

# main.rs
let temp_humidity = BluetoothGATTCharacteristic::new(bt_session, String::from("/org/bluez/hci0/dev_A4_C1_38_15_03_55/service0021/char0035"));
temp_humidity.start_notify().unwrap();
loop {
  for event in BluetoothSession::create_session(None).unwrap().incoming(1000).map(BluetoothEvent::from) {
    println!("event {:?}", event);
  }
}
# Terminal output
event Some(ServicesResolved { object_path: "/org/bluez/hci0/dev_A4_C1_38_15_03_55", services_resolved: true })
event None
event Some(Value { object_path: "/org/bluez/hci0/dev_A4_C1_38_15_03_55/service0021/char0035", value: [58, 10, 63, 192, 10] })
event Some(Value { object_path: "/org/bluez/hci0/dev_A4_C1_38_15_03_55/service0021/char0035", value: [56, 10, 63, 192, 10] })
event Some(Value { object_path: "/org/bluez/hci0/dev_A4_C1_38_15_03_55/service0021/char0035", value: [60, 10, 63, 192, 10] })
event Some(Value { object_path: "/org/bluez/hci0/dev_A4_C1_38_15_03_55/service0021/char0035", value: [57, 10, 63, 192, 10] })

Among other events, we can see the ones carrying the values that interest us. So now we need to decode this information. Humidity is straightforward, I can see it in value[2] but the Temperature actually kept me confused for a while, since I was looking for a float. Turns out, the first two bytes contain the temperature, transformed. Imagine the device's display reads 22.4ºC, the value of the two bytes as a i16 will actually be 2240.
2240 * 0.01 = 22.4.
As I write this, I realize I didn't test with negative temperatures... Maybe I'll get around to see if this still holds.

# src/main.rs
let value = match event.unwrap() {
  BluetoothEvent::Value {object_path : _, value} => value,
  _ => continue
};

// Converting to i16
let mut temperature_array = [0; 2];
temperature_array.clone_from_slice(&value[..2]);
let temperature = i16::from_le_bytes(temperature_array) as f32 * 0.01;

let humidity = value[2];

println!("Temperature: {:?}ºC Humidity: {:?}%", temperature, humidity);

And there we finally have it, the values in a human readable format.

# Terminal output
Temperature: 26.12ºC Humidity: 63%
Temperature: 26.09ºC Humidity: 63%
Temperature: 26.12ºC Humidity: 63%
Temperature: 26.12ºC Humidity: 63%

I hope this was interesting and helpful. I tried to avoid boilerplate code on the snippets, so be sure to check out the project on Github. And if you have any questions or comments, leave them below.

Posted on by:

Discussion

markdown guide
 

Thanks for sharing your experiences. It was really quick to get started with your help.

Me and my housemate are thinking of buying about 20 more of these and scattering them around our house, then sending temperature reports to Grafana via MQTT and a raspberry pi.

I would like to use your example as the base for our project, but my housemate says that there's a bunch of paperwork with his employer if he wants to contribute to a project that uses the Unlicense (opensource.google/docs/patching/). Is there any chance that you could release a version of your code that's under some other license? (BSD0 seems quite similar to unlicense in spirit, or dual-licensed-Apache-2.0-and-MIT if you want to have the same license as the rust-lang/rust repo)

If you want me to re-license it for you then I'm happy to send you a patch.

 

Hey! Super cool to see someone expand on this. I've updated to a dual-licensed-Apache-2.0-and-MIT as per your suggestion. Thanks!

 

Nice article, I'm going to try adopt to work with BLE "Triones" led strip. Previously I've tried Rust btleplug crate, but it looks like it doesn't support Services scan yet, so I was not able to get anything useful.