ESPHome 2026.8.0b4
Loading...
Searching...
No Matches
bthome_ble.cpp
Go to the documentation of this file.
1#include "bthome_ble.h"
2
4#include "esphome/core/log.h"
5
6#include <algorithm>
7#include <array>
8#include <cstring>
9#include <span>
10
11// AES-CCM backend for encrypted-advertisement (bindkey) decryption:
12// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed.
13// - every other platform -> the portable software AES-CCM in ble_device_base, so
14// decryption never depends on the SDK exposing mbedtls/PSA to application code
15// (e.g. LibreTiny beken-72xx keeps its mbedtls internal). Works on any BLE platform.
16#ifdef USE_ESP32
17#include <esp_idf_version.h>
18#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
19#include <psa/crypto.h>
20#define BTHOME_CRYPTO_PSA
21#endif
22#endif
23#ifndef BTHOME_CRYPTO_PSA
25#endif
26
28
29static const char *const TAG = "bthome_mithermometer";
30static constexpr size_t BTHOME_BINDKEY_SIZE = 16;
31static constexpr size_t BTHOME_NONCE_SIZE = 13;
32static constexpr size_t BTHOME_MIC_SIZE = 4;
33static constexpr size_t BTHOME_COUNTER_SIZE = 4;
34
35// Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they
36// compile away and an ungated helper trips -Wunused-function.
37#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
38static const char *format_mac_address(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer, uint64_t address) {
39 std::array<uint8_t, MAC_ADDRESS_SIZE> mac{};
40 for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
41 mac[i] = (address >> ((MAC_ADDRESS_SIZE - 1 - i) * 8)) & 0xFF;
42 }
43
44 format_mac_addr_upper(mac.data(), buffer.data());
45 return buffer.data();
46}
47#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
48
49static bool get_bthome_value_length(uint8_t obj_type, size_t &value_length) {
50 switch (obj_type) {
51 case 0x00: // packet id
52 case 0x01: // battery
53 case 0x09: // count (uint8)
54 case 0x0F: // generic boolean
55 case 0x10: // power (bool)
56 case 0x11: // opening
57 case 0x15: // battery low
58 case 0x16: // battery charging
59 case 0x17: // carbon monoxide
60 case 0x18: // cold
61 case 0x19: // connectivity
62 case 0x1A: // door
63 case 0x1B: // garage door
64 case 0x1C: // gas
65 case 0x1D: // heat
66 case 0x1E: // light
67 case 0x1F: // lock
68 case 0x20: // moisture
69 case 0x21: // motion
70 case 0x22: // moving
71 case 0x23: // occupancy
72 case 0x24: // plug
73 case 0x25: // presence
74 case 0x26: // problem
75 case 0x27: // running
76 case 0x28: // safety
77 case 0x29: // smoke
78 case 0x2A: // sound
79 case 0x2B: // tamper
80 case 0x2C: // vibration
81 case 0x2D: // water leak
82 case 0x2E: // humidity (uint8)
83 case 0x2F: // moisture (uint8)
84 case 0x46: // UV index
85 case 0x57: // temperature (sint8)
86 case 0x58: // temperature (0.35C step)
87 case 0x59: // count (sint8)
88 case 0x60: // channel
89 value_length = 1;
90 return true;
91 case 0x02: // temperature (0.01C)
92 case 0x03: // humidity
93 case 0x06: // mass (kg)
94 case 0x07: // mass (lb)
95 case 0x08: // dewpoint
96 case 0x0C: // voltage (mV)
97 case 0x0D: // pm2.5
98 case 0x0E: // pm10
99 case 0x12: // CO2
100 case 0x13: // TVOC
101 case 0x14: // moisture
102 case 0x3D: // count (uint16)
103 case 0x3F: // rotation
104 case 0x40: // distance (mm)
105 case 0x41: // distance (m)
106 case 0x43: // current (A)
107 case 0x44: // speed
108 case 0x45: // temperature (0.1C)
109 case 0x47: // volume (L)
110 case 0x48: // volume (mL)
111 case 0x49: // volume flow rate
112 case 0x4A: // voltage (0.1V)
113 case 0x51: // acceleration
114 case 0x52: // gyroscope
115 case 0x56: // conductivity
116 case 0x5A: // count (sint16)
117 case 0x5D: // current (sint16)
118 case 0x5E: // direction
119 case 0x5F: // precipitation
120 case 0x61: // rotational speed
121 case 0xF0: // button event
122 value_length = 2;
123 return true;
124 case 0x04: // pressure
125 case 0x05: // illuminance
126 case 0x0A: // energy
127 case 0x0B: // power
128 case 0x42: // duration
129 case 0x4B: // gas (uint24)
130 case 0xF2: // firmware version (uint24)
131 value_length = 3;
132 return true;
133 case 0x3E: // count (uint32)
134 case 0x4C: // gas (uint32)
135 case 0x4D: // energy (uint32)
136 case 0x4E: // volume (uint32)
137 case 0x4F: // water (uint32)
138 case 0x50: // timestamp
139 case 0x55: // volume storage
140 case 0x5B: // count (sint32)
141 case 0x5C: // power (sint32)
142 case 0x62: // speed (sint32)
143 case 0x63: // acceleration (sint32)
144 case 0xF1: // firmware version (uint32)
145 value_length = 4;
146 return true;
147 default:
148 return false;
149 }
150}
151
153 char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
154 ESP_LOGCONFIG(TAG, "BTHome MiThermometer");
155 ESP_LOGCONFIG(TAG, " MAC Address: %s", format_mac_address(addr_buf, this->address_));
156 if (this->has_bindkey_) {
157 char bindkey_hex[format_hex_pretty_size(BTHOME_BINDKEY_SIZE)];
158 ESP_LOGCONFIG(TAG, " Bindkey: %s", format_hex_pretty_to(bindkey_hex, this->bindkey_, BTHOME_BINDKEY_SIZE, '.'));
159 }
160 LOG_SENSOR(" ", "Temperature", this->temperature_);
161 LOG_SENSOR(" ", "Humidity", this->humidity_);
162 LOG_SENSOR(" ", "Battery Level", this->battery_level_);
163 LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_);
164 LOG_SENSOR(" ", "Signal Strength", this->signal_strength_);
165}
166
168 bool matched = false;
169 for (auto &service_data : device.get_service_datas()) {
170 if (this->handle_service_data_(service_data, device)) {
171 matched = true;
172 }
173 }
174 if (matched && this->signal_strength_ != nullptr) {
175 this->signal_strength_->publish_state(device.get_rssi());
176 }
177 return matched;
178}
179
180void BTHomeMiThermometer::set_bindkey(std::initializer_list<uint8_t> bindkey) {
181 if (bindkey.size() != sizeof(this->bindkey_)) {
182 ESP_LOGW(TAG, "BTHome bindkey size mismatch: %zu", bindkey.size());
183 return;
184 }
185 std::copy(bindkey.begin(), bindkey.end(), this->bindkey_);
186 this->has_bindkey_ = true;
187}
188
189bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector<uint8_t> &data, uint64_t source_address,
190 std::vector<uint8_t> &payload) const {
191 if (data.size() <= 1 + BTHOME_COUNTER_SIZE + BTHOME_MIC_SIZE) {
192 ESP_LOGVV(TAG, "Encrypted BTHome payload too short: %zu", data.size());
193 return false;
194 }
195
196 const size_t ciphertext_size = data.size() - 1 - BTHOME_COUNTER_SIZE - BTHOME_MIC_SIZE;
197 payload.resize(ciphertext_size);
198
199 std::array<uint8_t, MAC_ADDRESS_SIZE> mac{};
200 for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
201 mac[i] = (source_address >> ((MAC_ADDRESS_SIZE - 1 - i) * 8)) & 0xFF;
202 }
203
204 std::array<uint8_t, BTHOME_NONCE_SIZE> nonce{};
205 memcpy(nonce.data(), mac.data(), mac.size());
206 nonce[6] = 0xD2;
207 nonce[7] = 0xFC;
208 nonce[8] = data[0];
209 memcpy(nonce.data() + 9, &data[data.size() - BTHOME_COUNTER_SIZE - BTHOME_MIC_SIZE], BTHOME_COUNTER_SIZE);
210
211 const uint8_t *ciphertext = data.data() + 1;
212 const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE;
213
214#if defined(BTHOME_CRYPTO_PSA)
215 // PSA AEAD expects ciphertext + tag concatenated
216 // BLE advertisement max payload is 31 bytes, so this is always sufficient
217 static constexpr size_t MAX_CT_WITH_TAG = 32;
218 uint8_t ct_with_tag[MAX_CT_WITH_TAG];
219 size_t ct_with_tag_size = ciphertext_size + BTHOME_MIC_SIZE;
220 memcpy(ct_with_tag, ciphertext, ciphertext_size);
221 memcpy(ct_with_tag + ciphertext_size, mic, BTHOME_MIC_SIZE);
222
223 psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT;
224 psa_set_key_type(&attributes, PSA_KEY_TYPE_AES);
225 psa_set_key_bits(&attributes, BTHOME_BINDKEY_SIZE * 8);
226 psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT);
227 psa_set_key_algorithm(&attributes, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE));
228
229 mbedtls_svc_key_id_t key_id;
230 if (psa_import_key(&attributes, this->bindkey_, BTHOME_BINDKEY_SIZE, &key_id) != PSA_SUCCESS) {
231 ESP_LOGVV(TAG, "psa_import_key() failed.");
232 return false;
233 }
234
235 size_t plaintext_length;
236 // NOLINTNEXTLINE(readability-suspicious-call-argument) - similarly named size args are not swapped
237 psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE),
238 nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size,
239 payload.data(), ciphertext_size, &plaintext_length);
240 psa_destroy_key(key_id);
241 if (status != PSA_SUCCESS || plaintext_length != ciphertext_size) {
242 ESP_LOGVV(TAG, "BTHome decryption failed.");
243 return false;
244 }
245#else
246 // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency.
247 if (!ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce.data(), nonce.size(), nullptr, 0, ciphertext,
248 ciphertext_size, payload.data(), mic, BTHOME_MIC_SIZE)) {
249 ESP_LOGVV(TAG, "BTHome decryption failed.");
250 return false;
251 }
252#endif
253 return true;
254}
255
257 const ble_device_base::ESPBTDevice &device) {
258 if (!service_data.uuid.contains(0xD2, 0xFC)) {
259 return false;
260 }
261
262 const auto &data = service_data.data;
263 if (data.size() < 2) {
264 ESP_LOGVV(TAG, "BTHome data too short: %zu", data.size());
265 return false;
266 }
267
268 const uint8_t adv_info = data[0];
269 const bool is_encrypted = adv_info & 0x01;
270 const bool mac_included = adv_info & 0x02;
271 const bool is_trigger_based = adv_info & 0x04;
272 const uint8_t version = (adv_info >> 5) & 0x07;
273
274 if (version != 0x02) {
275 ESP_LOGVV(TAG, "Unsupported BTHome version %u", version);
276 return false;
277 }
278
279 uint64_t source_address = device.address_uint64();
280 bool address_matches = source_address == this->address_;
281 if (!is_encrypted && mac_included && data.size() >= 7) {
282 uint64_t advertised_address = 0;
283 for (int i = 5; i >= 0; i--) {
284 advertised_address = (advertised_address << 8) | data[1 + i];
285 }
286 address_matches = address_matches || advertised_address == this->address_;
287 }
288
289 if (is_encrypted && !this->has_bindkey_) {
290 if (address_matches) {
291 char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
292 ESP_LOGE(TAG, "Encrypted BTHome frame received but no bindkey configured for %s",
293 device.address_str_to(addr_buf));
294 }
295 return false;
296 }
297
298 if (!is_encrypted && this->has_bindkey_) {
299 if (address_matches) {
300 char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
301 ESP_LOGE(TAG, "Unencrypted BTHome frame received with bindkey configured for %s",
302 device.address_str_to(addr_buf));
303 }
304 return false;
305 }
306 std::vector<uint8_t> decrypted_payload;
307 const uint8_t *payload = nullptr;
308 size_t payload_size = 0;
309
310 if (is_encrypted) {
311 if (!this->decrypt_bthome_payload_(data, source_address, decrypted_payload)) {
312 char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
313 ESP_LOGVV(TAG, "Failed to decrypt BTHome frame from %s", device.address_str_to(addr_buf));
314 return false;
315 }
316 payload = decrypted_payload.data();
317 payload_size = decrypted_payload.size();
318 } else {
319 payload = data.data() + 1;
320 payload_size = data.size() - 1;
321 }
322
323 if (mac_included) {
324 if (payload_size < 6) {
325 ESP_LOGVV(TAG, "BTHome payload missing MAC address");
326 return false;
327 }
328 source_address = 0;
329 for (int i = 5; i >= 0; i--) {
330 source_address = (source_address << 8) | payload[i];
331 }
332 payload += 6;
333 payload_size -= 6;
334 }
335
336 char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
337 if (source_address != this->address_) {
338 ESP_LOGVV(TAG, "BTHome frame from unexpected device %s", format_mac_address(addr_buf, source_address));
339 return false;
340 }
341
342 if (payload_size == 0) {
343 ESP_LOGVV(TAG, "BTHome payload empty after header");
344 return false;
345 }
346
347 bool reported = false;
348 size_t offset = 0;
349 uint8_t last_type = 0;
350
351 while (offset < payload_size) {
352 const uint8_t obj_type = payload[offset++];
353 size_t value_length = 0;
354 bool has_length_byte = obj_type == 0x53; // text objects include explicit length
355
356 if (has_length_byte) {
357 if (offset >= payload_size) {
358 break;
359 }
360 value_length = payload[offset++];
361 } else {
362 if (!get_bthome_value_length(obj_type, value_length)) {
363 ESP_LOGVV(TAG, "Unknown BTHome object 0x%02X", obj_type);
364 break;
365 }
366 }
367
368 if (value_length == 0) {
369 break;
370 }
371
372 if (offset + value_length > payload_size) {
373 ESP_LOGVV(TAG, "BTHome object length exceeds payload");
374 break;
375 }
376
377 const uint8_t *value = &payload[offset];
378 offset += value_length;
379
380 if (obj_type < last_type) {
381 ESP_LOGVV(TAG, "BTHome objects not in ascending order");
382 }
383 last_type = obj_type;
384
385 switch (obj_type) {
386 case 0x00: { // packet id
387 const uint8_t packet_id = value[0];
388 if (this->last_packet_id_.has_value() && *this->last_packet_id_ == packet_id) {
389 return reported;
390 }
391 this->last_packet_id_ = packet_id;
392 break;
393 }
394 case 0x01: { // battery percentage
395 if (this->battery_level_ != nullptr) {
396 this->battery_level_->publish_state(value[0]);
397 reported = true;
398 }
399 break;
400 }
401 case 0x0C: { // battery voltage (mV)
402 if (this->battery_voltage_ != nullptr) {
403 const uint16_t raw = encode_uint16(value[1], value[0]);
404 this->battery_voltage_->publish_state(raw * 0.001f);
405 reported = true;
406 }
407 break;
408 }
409 case 0x02: { // temperature
410 if (this->temperature_ != nullptr) {
411 const int16_t raw = encode_uint16(value[1], value[0]);
412 this->temperature_->publish_state(raw * 0.01f);
413 reported = true;
414 }
415 break;
416 }
417 case 0x03: { // humidity
418 if (this->humidity_ != nullptr) {
419 const uint16_t raw = encode_uint16(value[1], value[0]);
420 this->humidity_->publish_state(raw * 0.01f);
421 reported = true;
422 }
423 break;
424 }
425 default:
426 break;
427 }
428 }
429
430 if (reported) {
431 ESP_LOGD(TAG, "BTHome data%sfrom %s", is_trigger_based ? " (triggered) " : " ", device.address_str_to(addr_buf));
432 }
433
434 return reported;
435}
436
437} // namespace esphome::bthome_mithermometer
uint8_t address
Definition bl0906.h:4
uint8_t raw[35]
Definition bl0939.h:0
uint8_t status
Definition bl0942.h:8
ESPDEPRECATED("Use address_str_to() instead. Removed in 2027.2.0.", "2026.8.0") std const char * address_str_to(char *buf) const
Return MAC as "XX:XX:XX:XX:XX:XX" string.
uint64_t address_uint64() const
Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64).
const std::vector< ServiceData > & get_service_datas() const
Definition ble_device.h:225
bool contains(uint8_t data1, uint8_t data2) const
True if the UUID value contains the adjacent byte pair (data1, data2).
bool parse_device(const ble_device_base::ESPBTDevice &device) override
void set_bindkey(std::initializer_list< uint8_t > bindkey)
bool handle_service_data_(const ble_device_base::ServiceData &service_data, const ble_device_base::ESPBTDevice &device)
bool decrypt_bthome_payload_(const std::vector< uint8_t > &data, uint64_t source_address, std::vector< uint8_t > &payload) const
void publish_state(float state)
Publish a new state to the front-end.
Definition sensor.cpp:68
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, const uint8_t *tag, size_t tag_len)
char * format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator)
Format byte array as uppercase hex to buffer (base implementation).
Definition helpers.cpp:406
constexpr size_t format_hex_pretty_size(size_t byte_count)
Calculate buffer size needed for format_hex_pretty_to with separator: "XX:XX:...:XX\0".
Definition helpers.h:1426
constexpr uint16_t encode_uint16(uint8_t msb, uint8_t lsb)
Encode a 16-bit value given the most and least significant byte.
Definition helpers.h:883
char * format_mac_addr_upper(const uint8_t *mac, char *output)
Format MAC address as XX:XX:XX:XX:XX:XX (uppercase, colon separators)
Definition helpers.h:1493
uint32_t payload_size()