use bluez_async::{
uuid_from_u16, BluetoothError, BluetoothEvent, BluetoothSession, CharacteristicEvent,
CharacteristicId, DeviceId, DeviceInfo,
};
use futures::future;
use futures::stream::{Stream, StreamExt};
use log::info;
use std::convert::TryInto;
use std::ops::Range;
use thiserror::Error;
use uuid::Uuid;
const BBQ_SERVICE_UUID: Uuid = uuid_from_u16(0xFFF0);
const SETTING_RESULT_UUID: Uuid = uuid_from_u16(0xFFF1);
const ACCOUNT_AND_VERIFY_UUID: Uuid = uuid_from_u16(0xFFF2);
const HISTORY_DATA_UUID: Uuid = uuid_from_u16(0xFFF3);
const REAL_TIME_DATA_UUID: Uuid = uuid_from_u16(0xFFF4);
const SETTING_DATA_UUID: Uuid = uuid_from_u16(0xFFF5);
const CREDENTIAL_MSG: [u8; 15] = [
0x21, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0xb8, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00,
];
const SET_TARGET_TEMP_COMMAND: u8 = 0x01;
const SET_UNIT_COMMAND: u8 = 0x02;
const REAL_TIME_DATA_COMMAND: u8 = 0x0B;
const REQUEST_PROPERTY_COMMAND: u8 = 0x08;
const UNITS_CELCIUS_ARGUMENT: u8 = 0x00;
const UNITS_FAHRENHEIT_ARGUMENT: u8 = 0x01;
const SILENCE_PRESSED: u8 = 0x04;
const BATTERY_LEVEL_PROPERTY_ID: u8 = 0x24;
const ACKNOWLEDGE_COMMAND: u8 = 0xFF;
const ABSENT_PROBE_VALUE: f32 = -1.0;
const TARGET_TEMP_NONE: f32 = -300.0;
const TEMPERATURE_MAX: f32 = i16::MAX as f32 / 10.0;
const TEMPERATURE_MIN: f32 = i16::MIN as f32 / 10.0;
const DEVICE_NAMES: [&str; 2] = ["BBQ", "iBBQ"];
#[derive(Debug, Error)]
pub enum Error {
#[error("Temperature {0} out of range")]
TemperatureEncodingError(f32),
#[error(transparent)]
Bluetooth(#[from] BluetoothError),
}
pub async fn find_devices(bt_session: &BluetoothSession) -> Result<Vec<DeviceInfo>, Error> {
let devices = bt_session.get_devices().await?;
Ok(devices
.into_iter()
.filter(BBQDevice::is_compatible)
.collect())
}
#[derive(Clone, Debug)]
pub struct BBQDevice {
bt_session: BluetoothSession,
setting_result_characteristic: CharacteristicId,
account_and_verify_characteristic: CharacteristicId,
history_data_characteristic: CharacteristicId,
real_time_data_characteristic: CharacteristicId,
setting_data_characteristic: CharacteristicId,
}
impl BBQDevice {
pub fn is_compatible(device: &DeviceInfo) -> bool {
matches!(&device.name, Some(name) if DEVICE_NAMES.contains(&name.as_str()))
}
pub async fn new(
bt_session: BluetoothSession,
device: DeviceId,
) -> Result<BBQDevice, BluetoothError> {
let service = bt_session
.get_service_by_uuid(&device, BBQ_SERVICE_UUID)
.await?
.id;
let setting_result_characteristic = bt_session
.get_characteristic_by_uuid(&service, SETTING_RESULT_UUID)
.await?
.id;
let account_and_verify_characteristic = bt_session
.get_characteristic_by_uuid(&service, ACCOUNT_AND_VERIFY_UUID)
.await?
.id;
let history_data_characteristic = bt_session
.get_characteristic_by_uuid(&service, HISTORY_DATA_UUID)
.await?
.id;
let real_time_data_characteristic = bt_session
.get_characteristic_by_uuid(&service, REAL_TIME_DATA_UUID)
.await?
.id;
let setting_data_characteristic = bt_session
.get_characteristic_by_uuid(&service, SETTING_DATA_UUID)
.await?
.id;
Ok(BBQDevice {
bt_session,
setting_result_characteristic,
account_and_verify_characteristic,
history_data_characteristic,
real_time_data_characteristic,
setting_data_characteristic,
})
}
pub async fn authenticate(&self) -> Result<(), BluetoothError> {
self.bt_session
.write_characteristic_value(&self.account_and_verify_characteristic, CREDENTIAL_MSG)
.await
}
pub async fn set_temperature_unit(&self, unit: TemperatureUnit) -> Result<(), BluetoothError> {
let argument = match unit {
TemperatureUnit::Celcius => UNITS_CELCIUS_ARGUMENT,
TemperatureUnit::Fahrenheit => UNITS_FAHRENHEIT_ARGUMENT,
};
let command = [SET_UNIT_COMMAND, argument, 0, 0, 0, 0];
self.bt_session
.write_characteristic_value(&self.setting_data_characteristic, command)
.await
}
pub async fn set_target_range(&self, probe: u8, range: Range<f32>) -> Result<(), Error> {
let bottom_bytes = encode_temperature(range.start)?;
let top_bytes = encode_temperature(range.end)?;
let value = [
SET_TARGET_TEMP_COMMAND,
probe,
bottom_bytes[0],
bottom_bytes[1],
top_bytes[0],
top_bytes[1],
];
self.bt_session
.write_characteristic_value(&self.setting_data_characteristic, value)
.await?;
Ok(())
}
pub async fn set_target_temp(&self, probe: u8, target: f32) -> Result<(), Error> {
self.set_target_range(probe, TARGET_TEMP_NONE..target).await
}
pub async fn enable_real_time_data(&self, enable: bool) -> Result<(), BluetoothError> {
let argument = if enable { 0x01 } else { 0x00 };
let command = [REAL_TIME_DATA_COMMAND, argument, 0, 0, 0, 0];
self.bt_session
.write_characteristic_value(&self.setting_data_characteristic, command)
.await
}
pub async fn request_battery_level(&self) -> Result<(), BluetoothError> {
let command = [
REQUEST_PROPERTY_COMMAND,
BATTERY_LEVEL_PROPERTY_ID,
0,
0,
0,
0,
];
self.bt_session
.write_characteristic_value(&self.setting_data_characteristic, command)
.await
}
pub async fn real_time(&self) -> Result<impl Stream<Item = RealTimeData>, BluetoothError> {
let real_time_data_characteristic = self.real_time_data_characteristic.clone();
self.bt_session
.start_notify(&real_time_data_characteristic)
.await?;
let events = self
.bt_session
.characteristic_event_stream(&real_time_data_characteristic)
.await?;
Ok(StreamExt::filter_map(events, move |event| {
future::ready(match event {
BluetoothEvent::Characteristic {
id,
event: CharacteristicEvent::Value { value },
} if id == real_time_data_characteristic => RealTimeData::try_parse(&value),
_ => {
info!("Unexpected Bluetooth event {:?}", event);
None
}
})
}))
}
pub async fn setting_results(
&self,
) -> Result<impl Stream<Item = SettingResult>, BluetoothError> {
let setting_result_characteristic = self.setting_result_characteristic.clone();
self.bt_session
.start_notify(&setting_result_characteristic)
.await?;
let events = self
.bt_session
.characteristic_event_stream(&setting_result_characteristic)
.await?;
Ok(StreamExt::filter_map(events, move |event| {
future::ready(match event {
BluetoothEvent::Characteristic {
id,
event: CharacteristicEvent::Value { value },
} if id == setting_result_characteristic => SettingResult::try_parse(&value),
_ => {
info!("Unexpected Bluetooth event {:?}", event);
None
}
})
}))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TemperatureUnit {
Celcius,
Fahrenheit,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RealTimeData {
pub probe_temperatures: Vec<Option<f32>>,
}
impl RealTimeData {
fn try_parse(value: &[u8]) -> Option<RealTimeData> {
if value.len() % 2 != 0 {
return None;
}
Some(RealTimeData {
probe_temperatures: value
.chunks_exact(2)
.map(|bytes| {
let temperature = decode_temperature(bytes.try_into().unwrap());
if temperature == ABSENT_PROBE_VALUE {
None
} else {
Some(temperature)
}
})
.collect(),
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SettingResult {
AcknowledgeCommand { command_id: u8 },
BatteryLevel {
current_voltage: u16,
max_voltage: u16,
},
SilencePressed,
}
impl SettingResult {
fn try_parse(value: &[u8]) -> Option<SettingResult> {
if value.len() != 6 {
return None;
}
match value[0] {
ACKNOWLEDGE_COMMAND => {
assert!(value[2..] == [0, 0, 0, 0]);
Some(SettingResult::AcknowledgeCommand {
command_id: value[1],
})
}
BATTERY_LEVEL_PROPERTY_ID => Some(SettingResult::BatteryLevel {
current_voltage: u16::from_le_bytes(value[1..=2].try_into().unwrap()),
max_voltage: u16::from_le_bytes(value[3..=4].try_into().unwrap()),
}),
SILENCE_PRESSED => {
assert!(value[1..] == [0xFF, 0, 0, 0, 0]);
Some(SettingResult::SilencePressed)
}
_ => {
info!("Unrecognised setting result: {:?}", value);
None
}
}
}
}
fn encode_temperature(temperature: f32) -> Result<[u8; 2], Error> {
if temperature < TEMPERATURE_MIN || temperature > TEMPERATURE_MAX {
return Err(Error::TemperatureEncodingError(temperature));
}
let temperature_fixed = (temperature * 10.0) as i16;
Ok(temperature_fixed.to_le_bytes())
}
fn decode_temperature(bytes: [u8; 2]) -> f32 {
i16::from_le_bytes(bytes) as f32 / 10.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_real_time_invalid() {
assert_eq!(RealTimeData::try_parse(&[0]), None);
}
#[test]
fn parse_real_time_no_probes() {
assert_eq!(
RealTimeData::try_parse(&[0xF6, 0xFF, 0xF6, 0xFF]),
Some(RealTimeData {
probe_temperatures: vec![None, None]
})
);
}
#[test]
fn parse_real_time() {
assert_eq!(
RealTimeData::try_parse(&[1, 2, 3, 4]),
Some(RealTimeData {
probe_temperatures: vec![Some(51.3), Some(102.7)]
})
);
}
#[test]
fn parse_setting_result_invalid() {
assert_eq!(SettingResult::try_parse(&[]), None);
}
#[test]
fn parse_setting_result_acknowledge() {
assert_eq!(
SettingResult::try_parse(&[0xFF, 0x02, 0x00, 0x00, 0x00, 0x00]),
Some(SettingResult::AcknowledgeCommand { command_id: 0x02 })
);
}
#[test]
fn parse_setting_result_battery_level() {
assert_eq!(
SettingResult::try_parse(&[0x24, 0x5B, 0x17, 0x96, 0x19, 0x00]),
Some(SettingResult::BatteryLevel {
current_voltage: 5979,
max_voltage: 6550
})
);
}
#[test]
fn parse_setting_result_silence_pressed() {
assert_eq!(
SettingResult::try_parse(&[0x04, 0xFF, 0x00, 0x00, 0x00, 0x00]),
Some(SettingResult::SilencePressed)
);
}
}