ruuvi_decoders/
lib.rs

1//! Ruuvi BLE Advertisement Decoders
2//!
3//! This crate provides decoders for Ruuvi sensor BLE advertisements supporting:
4//! - Data Format 5 (`RAWv2`)
5//! - Data Format 6 (`RAWv3`)
6//! - Data Format E1 (Encrypted)
7//!
8//! # Example
9//!
10//! ```rust
11//! use ruuvi_decoders::{decode, RuuviData};
12//!
13//! let hex_data = "0512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F";
14//! let decoded = decode(hex_data).unwrap();
15//!
16//! match decoded {
17//!     RuuviData::V5(data) => {
18//!         println!("Temperature: {:?}°C", data.temperature);
19//!         println!("Humidity: {:?}%", data.humidity);
20//!     },
21//!     _ => println!("Other format"),
22//! }
23//! ```
24
25pub mod air_quality;
26pub mod e1;
27pub mod error;
28pub mod ruuvi_data;
29pub mod v5;
30pub mod v6;
31
32pub use error::{DecodeError, Result};
33pub use ruuvi_data::{DataFormat, RuuviData};
34
35/// Main entry point for decoding Ruuvi BLE advertisement data
36///
37/// # Arguments
38///
39/// * `hex_data` - Hex string of the Ruuvi payload (without the 9904 manufacturer prefix)
40///
41/// # Returns
42///
43/// * `Ok(RuuviData)` - Successfully decoded data
44/// * `Err(DecodeError)` - Decoding failed
45///
46/// # Example
47///
48/// ```rust
49/// use ruuvi_decoders::decode;
50///
51/// let hex_data = "0512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F";
52/// let result = decode(hex_data).unwrap();
53/// ```
54///
55/// # Errors
56///
57/// * `DecodeError::InvalidHex` - Invalid hex string
58/// * `DecodeError::InvalidLength` - Invalid length of hex string
59/// * `DecodeError::UnsupportedFormat` - Unsupported data format
60pub fn decode(hex_data: &str) -> Result<RuuviData> {
61    // Clean up hex string - remove whitespace and 0x prefix if present
62    let clean_hex = hex_data.trim().trim_start_matches("0x").replace(' ', "");
63
64    // Convert hex to bytes
65    let bytes = hex_to_bytes(&clean_hex)?;
66
67    if bytes.is_empty() {
68        return Err(DecodeError::InvalidLength("Empty data".into()));
69    }
70
71    // Determine data format from first byte
72    match bytes[0] {
73        5 => {
74            let data = v5::decode(&bytes)?;
75            Ok(RuuviData::V5(data))
76        }
77        6 => {
78            let data = v6::decode(&bytes)?;
79            Ok(RuuviData::V6(data))
80        }
81        0xE1 => {
82            let data = e1::decode(&bytes)?;
83            Ok(RuuviData::E1(data))
84        }
85        format => Err(DecodeError::UnsupportedFormat(format)),
86    }
87}
88
89/// Extract Ruuvi data from a full BLE advertisement
90///
91/// Looks for the Ruuvi manufacturer data (0x9904) and extracts the payload
92///
93/// # Arguments
94///
95/// * `ble_data` - Full BLE advertisement hex string
96///
97/// # Returns
98///
99/// * `Some(String)` - Extracted Ruuvi payload hex
100/// * `None` - No Ruuvi data found
101#[must_use]
102pub fn extract_ruuvi_from_ble(ble_data: &str) -> Option<String> {
103    let clean_data = ble_data.trim().to_uppercase();
104
105    // Validate hex format
106    if !clean_data.chars().all(|c| c.is_ascii_hexdigit()) {
107        return None;
108    }
109
110    // Look for Ruuvi manufacturer ID (0x9904 in little-endian format in BLE ads)
111    // The actual pattern in BLE advertisements could be "9904" or "0499" depending on endianness
112    for pattern in ["9904", "0499"] {
113        if let Some(start_idx) = clean_data.find(pattern) {
114            if start_idx != 0 {
115                continue;
116            }
117
118            let payload_start = start_idx + 4; // Skip the 4-char manufacturer ID
119
120            // Extract payload - length depends on format, but we'll try to get a reasonable amount
121            // Data Format 5 should be 24 bytes = 48 hex chars
122            if payload_start <= clean_data.len() {
123                let payload = &clean_data[payload_start..];
124                return Some(payload.to_string());
125            }
126        }
127    }
128
129    None
130}
131
132/// Convert hex string to bytes
133fn hex_to_bytes(hex_str: &str) -> Result<Vec<u8>> {
134    if !hex_str.len().is_multiple_of(2) {
135        return Err(DecodeError::InvalidHex(format!(
136            "Odd number of hex characters: {}",
137            hex_str.len()
138        )));
139    }
140
141    hex::decode(hex_str).map_err(|_| DecodeError::InvalidHex(hex_str.to_string()))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_hex_to_bytes() {
150        assert_eq!(hex_to_bytes("01FF").unwrap(), vec![0x01, 0xFF]);
151        assert_eq!(hex_to_bytes("").unwrap(), Vec::<u8>::new());
152        assert!(hex_to_bytes("0").is_err()); // Odd length
153        assert!(hex_to_bytes("GG").is_err()); // Invalid hex
154    }
155
156    #[test]
157    fn test_extract_ruuvi_from_ble() {
158        let ble_data = "99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F";
159        let payload = extract_ruuvi_from_ble(ble_data).expect("Failed to extract Ruuvi data");
160        assert_eq!(
161            payload,
162            "0512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F".to_string()
163        );
164        assert_eq!(payload.len(), v5::PAYLOAD_WITH_MAC_LENGTH * 2);
165        assert!(payload.starts_with("05")); // Data Format 5
166
167        // Test with 0499 pattern (little-endian)
168        let ble_data_le = "04990512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F";
169        let extracted_le = extract_ruuvi_from_ble(ble_data_le);
170        assert!(extracted_le.is_some());
171
172        // Test with no Ruuvi data
173        let non_ruuvi = "020106030316910255AA";
174        assert!(extract_ruuvi_from_ble(non_ruuvi).is_none());
175    }
176
177    #[test]
178    fn test_decode_empty_data() {
179        assert!(decode("").is_err());
180    }
181
182    #[test]
183    fn test_unsupported_format() {
184        // Format 99 doesn't exist
185        let result = decode("63000000000000000000000000000000000000000000000000");
186        assert!(matches!(result, Err(DecodeError::UnsupportedFormat(99))));
187    }
188
189    #[test]
190    fn test_decoding_ruuvi_data() {
191        let ble_data = "99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F";
192        let payload = decode(&extract_ruuvi_from_ble(ble_data).expect("ble_data"))
193            .expect("Failed to extract Ruuvi data");
194        match payload {
195            RuuviData::V5(_) => (),
196            _ => panic!("Unexpected data format"),
197        }
198
199        let ble_data = "990406170C5668C79E007000C90501D9FFCD004C884F";
200        let payload = decode(&extract_ruuvi_from_ble(ble_data).expect("ble_data"))
201            .expect("Failed to extract Ruuvi data");
202        match payload {
203            RuuviData::V6(_) => (),
204            _ => panic!("Unexpected data format"),
205        }
206
207        let ble_data =
208            "9904E1170C5668C79E0065007004BD11CA00C90A0213E0AC000000DECDEE100000000000CBB8334C884F";
209        let payload = decode(&extract_ruuvi_from_ble(ble_data).expect("ble_data"))
210            .expect("Failed to extract Ruuvi data");
211        match payload {
212            RuuviData::E1(_) => (),
213            _ => panic!("Unexpected data format"),
214        }
215    }
216}