[bthome_ble] Add BTHome BLE packet parser component

Implements a BTHome v2 BLE advertisement parser component that allows ESPHome
to receive and parse sensor data from BTHome-compatible BLE devices.

Features:
- Parse BTHome v2 format BLE advertisements
- Support for sensors and binary sensors
- AES-CCM encryption support with 16-byte bind keys
- Independent sensor configuration (each sensor specifies MAC address and bind key)
- Supports 50+ BTHome object types including temperature, humidity, motion, door, etc.

Component structure:
- Main component: bthome_ble (BLE device listener)
- Sensor platform: supports numeric sensor types
- Binary sensor platform: supports boolean sensor types

Each sensor/binary_sensor independently listens for BLE advertisements from
the configured MAC address and extracts the specified object type. Multiple
sensors can monitor the same device by specifying the same MAC address and
bind key (if encrypted).

This is the complementary receiver component to the BTHome transmitter.
This commit is contained in:
Claude
2025-11-18 20:23:15 +00:00
parent 70ed9c7c4d
commit 21539bdeba
11 changed files with 1396 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_BINDKEY
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["esp32_ble_tracker"]
bthome_ble_ns = cg.esphome_ns.namespace("bthome_ble")
BTHomeListener = bthome_ble_ns.class_(
"BTHomeListener", esp32_ble_tracker.ESPBTDeviceListener
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BTHomeListener),
cv.Optional(CONF_BINDKEY): cv.bind_key,
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await esp32_ble_tracker.register_ble_device(var, config)
if bindkey := config.get(CONF_BINDKEY):
cg.add(var.set_bindkey(bindkey))

View File

@@ -0,0 +1,138 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import (
CONF_BINDKEY,
CONF_ID,
CONF_TYPE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_COLD,
DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_DOOR,
DEVICE_CLASS_GARAGE_DOOR,
DEVICE_CLASS_GAS,
DEVICE_CLASS_HEAT,
DEVICE_CLASS_LIGHT,
DEVICE_CLASS_LOCK,
DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_MOVING,
DEVICE_CLASS_OCCUPANCY,
DEVICE_CLASS_OPENING,
DEVICE_CLASS_PLUG,
DEVICE_CLASS_POWER,
DEVICE_CLASS_PRESENCE,
DEVICE_CLASS_PROBLEM,
DEVICE_CLASS_RUNNING,
DEVICE_CLASS_SAFETY,
DEVICE_CLASS_SMOKE,
DEVICE_CLASS_SOUND,
DEVICE_CLASS_TAMPER,
DEVICE_CLASS_VIBRATION,
DEVICE_CLASS_WINDOW,
)
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["bthome_ble"]
bthome_ble_ns = cg.esphome_ns.namespace("bthome_ble")
BTHomeBinarySensor = bthome_ble_ns.class_(
"BTHomeBinarySensor", binary_sensor.BinarySensor, esp32_ble_tracker.ESPBTDeviceListener
)
# BTHome object ID type mapping for binary sensors
BINARY_SENSOR_TYPES = {
"battery_low": 0x15,
"battery_charging": 0x16,
"carbon_monoxide": 0x17,
"cold": 0x18,
"connectivity": 0x19,
"door": 0x1A,
"garage_door": 0x1B,
"gas": 0x1C,
"heat": 0x1D,
"light": 0x1E,
"lock": 0x1F,
"moisture": 0x20,
"motion": 0x21,
"moving": 0x22,
"occupancy": 0x23,
"opening": 0x24,
"plug": 0x25,
"power_on": 0x26,
"presence": 0x27,
"problem": 0x28,
"running": 0x29,
"safety": 0x2A,
"smoke": 0x2B,
"sound": 0x2C,
"tamper": 0x2D,
"vibration": 0x2E,
"window": 0x2F,
}
# Default configurations for binary sensor types
BINARY_SENSOR_DEFAULTS = {
"battery_low": {"device_class": DEVICE_CLASS_BATTERY},
"battery_charging": {"device_class": DEVICE_CLASS_BATTERY_CHARGING},
"cold": {"device_class": DEVICE_CLASS_COLD},
"connectivity": {"device_class": DEVICE_CLASS_CONNECTIVITY},
"door": {"device_class": DEVICE_CLASS_DOOR},
"garage_door": {"device_class": DEVICE_CLASS_GARAGE_DOOR},
"gas": {"device_class": DEVICE_CLASS_GAS},
"heat": {"device_class": DEVICE_CLASS_HEAT},
"light": {"device_class": DEVICE_CLASS_LIGHT},
"lock": {"device_class": DEVICE_CLASS_LOCK},
"moisture": {"device_class": DEVICE_CLASS_MOISTURE},
"motion": {"device_class": DEVICE_CLASS_MOTION},
"moving": {"device_class": DEVICE_CLASS_MOVING},
"occupancy": {"device_class": DEVICE_CLASS_OCCUPANCY},
"opening": {"device_class": DEVICE_CLASS_OPENING},
"plug": {"device_class": DEVICE_CLASS_PLUG},
"power_on": {"device_class": DEVICE_CLASS_POWER},
"presence": {"device_class": DEVICE_CLASS_PRESENCE},
"problem": {"device_class": DEVICE_CLASS_PROBLEM},
"running": {"device_class": DEVICE_CLASS_RUNNING},
"safety": {"device_class": DEVICE_CLASS_SAFETY},
"smoke": {"device_class": DEVICE_CLASS_SMOKE},
"sound": {"device_class": DEVICE_CLASS_SOUND},
"tamper": {"device_class": DEVICE_CLASS_TAMPER},
"vibration": {"device_class": DEVICE_CLASS_VIBRATION},
"window": {"device_class": DEVICE_CLASS_WINDOW},
}
def apply_defaults(config):
"""Apply default configurations based on binary sensor type"""
sensor_type = config[CONF_TYPE]
if sensor_type in BINARY_SENSOR_DEFAULTS:
defaults = BINARY_SENSOR_DEFAULTS[sensor_type]
for key, value in defaults.items():
if key not in config:
config[key] = value
return config
CONFIG_SCHEMA = cv.All(
binary_sensor.binary_sensor_schema(BTHomeBinarySensor)
.extend(
{
cv.Required(CONF_TYPE): cv.enum(BINARY_SENSOR_TYPES, lower=True),
cv.Optional(CONF_BINDKEY): cv.bind_key,
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
apply_defaults,
)
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
await esp32_ble_tracker.register_ble_device(var, config)
object_id = BINARY_SENSOR_TYPES[config[CONF_TYPE]]
cg.add(var.set_object_id(object_id))
if bindkey := config.get(CONF_BINDKEY):
cg.add(var.set_bindkey(bindkey))

View File

@@ -0,0 +1,153 @@
#include "bthome_binary_sensor.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include <mbedtls/ccm.h>
namespace esphome {
namespace bthome_ble {
static const char *const TAG = "bthome_ble.binary_sensor";
// BTHome v2 service UUID
static const uint16_t BTHOME_SERVICE_UUID = 0xFCD2;
// Device info byte masks
static const uint8_t BTHOME_ENCRYPTION_FLAG = 0x01;
void BTHomeBinarySensor::set_bindkey(const std::string &bindkey) {
if (bindkey.length() != 32) {
ESP_LOGE(TAG, "Bindkey must be 32 characters (16 bytes)");
return;
}
this->bindkey_ = bindkey;
}
bool BTHomeBinarySensor::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// Look for BTHome service data
const auto &service_datas = device.get_service_datas();
for (const auto &service_data : service_datas) {
if (service_data.uuid.get_uuid().len != ESP_UUID_LEN_16) {
continue;
}
if (service_data.uuid.get_uuid().uuid.uuid16 != BTHOME_SERVICE_UUID) {
continue;
}
ESP_LOGVV(TAG, "Found BTHome device: %s for binary sensor object_id 0x%02X", device.address_str().c_str(),
this->object_id_);
if (service_data.data.empty()) {
ESP_LOGW(TAG, "Empty service data");
return false;
}
// Copy service data to mutable vector
std::vector<uint8_t> payload(service_data.data.begin(), service_data.data.end());
// Parse device info byte
uint8_t device_info = payload[0];
bool is_encrypted = (device_info & BTHOME_ENCRYPTION_FLAG) != 0;
// Handle encryption
if (is_encrypted) {
if (!this->bindkey_.has_value()) {
ESP_LOGW(TAG, "Encrypted payload but no bindkey configured");
return false;
}
// Convert bindkey from hex string to bytes
uint8_t key[16];
for (int i = 0; i < 16; i++) {
key[i] = (uint8_t) strtol(this->bindkey_.value().substr(i * 2, 2).c_str(), nullptr, 16);
}
// Build nonce: address (6 bytes) + UUID (2 bytes) + device info (1 byte) + counter (4 bytes)
uint8_t nonce[13];
uint64_t address = device.address_uint64();
nonce[0] = (address >> 40) & 0xFF;
nonce[1] = (address >> 32) & 0xFF;
nonce[2] = (address >> 24) & 0xFF;
nonce[3] = (address >> 16) & 0xFF;
nonce[4] = (address >> 8) & 0xFF;
nonce[5] = address & 0xFF;
nonce[6] = 0xD2; // UUID LSB
nonce[7] = 0xFC; // UUID MSB
nonce[8] = device_info;
// Extract packet ID as counter if present
uint32_t count = 0;
if (payload.size() > 6 && payload[1] == 0x00) { // Packet ID present
count = payload[2];
}
nonce[9] = (count >> 24) & 0xFF;
nonce[10] = (count >> 16) & 0xFF;
nonce[11] = (count >> 8) & 0xFF;
nonce[12] = count & 0xFF;
// Tag is last 4 bytes of payload
if (payload.size() < 5) {
ESP_LOGW(TAG, "Encrypted payload too short");
return false;
}
uint8_t tag[4];
size_t ciphertext_len = payload.size() - 5;
memcpy(tag, &payload[payload.size() - 4], 4);
// Decrypt using AES-CCM
mbedtls_ccm_context ctx;
mbedtls_ccm_init(&ctx);
int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, key, 128);
if (ret != 0) {
ESP_LOGW(TAG, "Failed to set decryption key: %d", ret);
mbedtls_ccm_free(&ctx);
return false;
}
std::vector<uint8_t> plaintext(ciphertext_len);
ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_len, nonce, 13, nullptr, 0, &payload[1], plaintext.data(), tag,
4);
mbedtls_ccm_free(&ctx);
if (ret != 0) {
ESP_LOGVV(TAG, "Failed to decrypt payload: %d", ret);
return false;
}
// Replace encrypted data with decrypted data
payload.resize(1 + ciphertext_len);
memcpy(&payload[1], plaintext.data(), ciphertext_len);
}
// Parse measurement data looking for our object ID
BTHomeParseResult result;
if (payload.size() > 1) {
if (!parse_bthome_data_byte(&payload[1], payload.size() - 1, result)) {
ESP_LOGW(TAG, "Failed to parse BTHome data");
return false;
}
// Check if our binary sensor object ID is present
auto it = result.binary_sensors.find(this->object_id_);
if (it != result.binary_sensors.end()) {
bool value = it->second;
ESP_LOGD(TAG, "Binary sensor 0x%02X: %s", this->object_id_, value ? "ON" : "OFF");
this->publish_state(value);
return true;
}
}
}
return false;
}
} // namespace bthome_ble
} // namespace esphome
#endif

View File

@@ -0,0 +1,25 @@
#pragma once
#include "../bthome_ble.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#ifdef USE_ESP32
namespace esphome {
namespace bthome_ble {
class BTHomeBinarySensor : public binary_sensor::BinarySensor, public esp32_ble_tracker::ESPBTDeviceListener {
public:
void set_object_id(uint8_t object_id) { this->object_id_ = object_id; }
void set_bindkey(const std::string &bindkey);
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
protected:
uint8_t object_id_{0};
optional<std::string> bindkey_;
};
} // namespace bthome_ble
} // namespace esphome
#endif

View File

@@ -0,0 +1,329 @@
#include "bthome_ble.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include <mbedtls/ccm.h>
namespace esphome {
namespace bthome_ble {
static const char *const TAG = "bthome_ble";
// BTHome v2 service UUID
static const uint16_t BTHOME_SERVICE_UUID = 0xFCD2;
// Device info byte masks
static const uint8_t BTHOME_ENCRYPTION_FLAG = 0x01;
static const uint8_t BTHOME_TRIGGER_FLAG = 0x04;
static const uint8_t BTHOME_VERSION_MASK = 0xE0;
struct BTHomeObjectSpec {
uint8_t size; // Size in bytes (0 for variable length)
float factor; // Multiplication factor for decoding
bool is_signed; // Whether the value is signed
bool is_binary; // Whether this is a binary sensor
};
// BTHome object specifications
static const std::map<uint8_t, BTHomeObjectSpec> BTHOME_OBJECTS = {
{PACKET_ID, {1, 1, false, false}},
{BATTERY, {1, 1, false, false}},
{TEMPERATURE, {2, 0.01, true, false}},
{HUMIDITY, {2, 0.01, false, false}},
{PRESSURE, {3, 0.01, false, false}},
{ILLUMINANCE, {3, 0.01, false, false}},
{MASS_KG, {2, 0.01, false, false}},
{MASS_LB, {2, 0.01, false, false}},
{DEWPOINT, {2, 0.01, true, false}},
{COUNT, {1, 1, false, false}},
{ENERGY, {3, 0.001, false, false}},
{POWER, {3, 0.01, false, false}},
{VOLTAGE, {2, 0.001, false, false}},
{PM25, {2, 1, false, false}},
{PM10, {2, 1, false, false}},
{CO2, {2, 1, false, false}},
{VOC, {2, 1, false, false}},
{MOISTURE, {2, 0.01, false, false}},
{BATTERY_LOW, {1, 1, false, true}},
{BATTERY_CHARGING, {1, 1, false, true}},
{CARBON_MONOXIDE, {1, 1, false, true}},
{COLD, {1, 1, false, true}},
{CONNECTIVITY, {1, 1, false, true}},
{DOOR, {1, 1, false, true}},
{GARAGE_DOOR, {1, 1, false, true}},
{GAS, {1, 1, false, true}},
{HEAT, {1, 1, false, true}},
{LIGHT, {1, 1, false, true}},
{LOCK, {1, 1, false, true}},
{MOISTURE_BOOL, {1, 1, false, true}},
{MOTION, {1, 1, false, true}},
{MOVING, {1, 1, false, true}},
{OCCUPANCY, {1, 1, false, true}},
{OPENING, {1, 1, false, true}},
{PLUG, {1, 1, false, true}},
{POWER_ON, {1, 1, false, true}},
{PRESENCE, {1, 1, false, true}},
{PROBLEM, {1, 1, false, true}},
{RUNNING, {1, 1, false, true}},
{SAFETY, {1, 1, false, true}},
{SMOKE, {1, 1, false, true}},
{SOUND, {1, 1, false, true}},
{TAMPER, {1, 1, false, true}},
{VIBRATION, {1, 1, false, true}},
{WINDOW, {1, 1, false, true}},
{COUNT_UINT16, {2, 1, false, false}},
{COUNT_UINT32, {4, 1, false, false}},
{ROTATION, {2, 0.1, true, false}},
{DISTANCE_MM, {2, 1, false, false}},
{DISTANCE_M, {2, 0.1, false, false}},
{DURATION, {3, 0.001, false, false}},
{CURRENT, {2, 0.001, false, false}},
{SPEED, {2, 0.01, false, false}},
{TEMPERATURE_PRECISE, {2, 0.1, true, false}},
{UV_INDEX, {1, 0.1, false, false}},
{VOLUME_L, {2, 0.1, false, false}},
{VOLUME_ML, {2, 1, false, false}},
{VOLUME_FLOW, {2, 0.001, false, false}},
{VOLTAGE_PRECISE, {2, 0.1, false, false}},
{GAS_VOLUME, {3, 0.001, false, false}},
{GAS_VOLUME_L, {3, 0.001, false, false}},
{ENERGY_PRECISE, {4, 0.001, false, false}},
{VOLUME_PRECISE, {4, 0.001, false, false}},
{WATER, {3, 0.001, false, false}},
{TIMESTAMP, {4, 1, false, false}},
{ACCELERATION, {2, 0.001, true, false}},
{GYROSCOPE, {2, 0.001, true, false}},
};
void BTHomeListener::set_bindkey(const std::string &bindkey) {
if (bindkey.length() != 32) {
ESP_LOGE(TAG, "Bindkey must be 32 characters (16 bytes)");
return;
}
this->bindkey_ = bindkey;
}
bool BTHomeListener::decrypt_payload(std::vector<uint8_t> &payload, const uint64_t &address,
const uint32_t &count) {
if (!this->bindkey_.has_value()) {
ESP_LOGE(TAG, "Bindkey required for encrypted payload");
return false;
}
// Convert bindkey from hex string to bytes
uint8_t key[16];
for (int i = 0; i < 16; i++) {
key[i] = (uint8_t) strtol(this->bindkey_.value().substr(i * 2, 2).c_str(), nullptr, 16);
}
// Build nonce: address (6 bytes) + UUID (2 bytes) + device info (1 byte)
uint8_t nonce[13];
nonce[0] = (address >> 40) & 0xFF;
nonce[1] = (address >> 32) & 0xFF;
nonce[2] = (address >> 24) & 0xFF;
nonce[3] = (address >> 16) & 0xFF;
nonce[4] = (address >> 8) & 0xFF;
nonce[5] = address & 0xFF;
nonce[6] = 0xD2; // UUID LSB
nonce[7] = 0xFC; // UUID MSB
nonce[8] = payload[0]; // Device info byte
// Counter (4 bytes) - use packet ID if available, otherwise count
nonce[9] = (count >> 24) & 0xFF;
nonce[10] = (count >> 16) & 0xFF;
nonce[11] = (count >> 8) & 0xFF;
nonce[12] = count & 0xFF;
// Tag is last 4 bytes of payload
if (payload.size() < 5) { // Device info + at least 1 data byte + 4 byte tag
ESP_LOGE(TAG, "Encrypted payload too short");
return false;
}
uint8_t tag[4];
size_t ciphertext_len = payload.size() - 5; // Exclude device info and tag
memcpy(tag, &payload[payload.size() - 4], 4);
// Decrypt using AES-CCM
mbedtls_ccm_context ctx;
mbedtls_ccm_init(&ctx);
int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, key, 128);
if (ret != 0) {
ESP_LOGE(TAG, "Failed to set decryption key: %d", ret);
mbedtls_ccm_free(&ctx);
return false;
}
std::vector<uint8_t> plaintext(ciphertext_len);
ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_len, nonce, 13, nullptr, 0, &payload[1], plaintext.data(), tag, 4);
mbedtls_ccm_free(&ctx);
if (ret != 0) {
ESP_LOGE(TAG, "Failed to decrypt payload: %d", ret);
return false;
}
// Replace encrypted data with decrypted data (keep device info byte)
payload.resize(1 + ciphertext_len);
memcpy(&payload[1], plaintext.data(), ciphertext_len);
return true;
}
bool parse_bthome_data_byte(const uint8_t *data, uint8_t data_length, BTHomeParseResult &result) {
uint8_t offset = 0;
while (offset < data_length) {
if (offset >= data_length) {
ESP_LOGW(TAG, "Unexpected end of data");
return false;
}
uint8_t obj_id = data[offset++];
// Check if this is a known object type
auto it = BTHOME_OBJECTS.find(obj_id);
if (it == BTHOME_OBJECTS.end()) {
ESP_LOGW(TAG, "Unknown object ID: 0x%02X", obj_id);
return false;
}
const BTHomeObjectSpec &spec = it->second;
uint8_t obj_size = spec.size;
// Handle variable length objects
if (obj_size == 0) {
if (offset >= data_length) {
ESP_LOGW(TAG, "Missing length byte for variable object");
return false;
}
obj_size = data[offset++];
}
if (offset + obj_size > data_length) {
ESP_LOGW(TAG, "Object data exceeds payload length");
return false;
}
// Parse the value
if (spec.is_binary) {
bool value = data[offset] != 0;
result.binary_sensors[obj_id] = value;
offset += obj_size;
} else if (obj_id == TEXT) {
std::string value((char *) &data[offset], obj_size);
result.text_sensors[obj_id] = value;
offset += obj_size;
} else if (obj_id == RAW) {
// Skip raw data for now
offset += obj_size;
} else {
// Parse numeric value
int32_t raw_value = 0;
if (spec.is_signed) {
// Handle signed integers
switch (obj_size) {
case 1:
raw_value = (int8_t) data[offset];
break;
case 2:
raw_value = (int16_t) (data[offset] | (data[offset + 1] << 8));
break;
case 3:
raw_value = (int32_t) (data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16));
// Sign extend 24-bit to 32-bit
if (raw_value & 0x800000) {
raw_value |= 0xFF000000;
}
break;
case 4:
raw_value = (int32_t) (data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) |
(data[offset + 3] << 24));
break;
}
} else {
// Handle unsigned integers
uint32_t unsigned_value = 0;
for (int i = 0; i < obj_size; i++) {
unsigned_value |= ((uint32_t) data[offset + i] << (i * 8));
}
raw_value = unsigned_value;
}
float value = raw_value * spec.factor;
result.sensors[obj_id] = value;
offset += obj_size;
}
}
return true;
}
bool BTHomeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// Look for BTHome service data
const auto &service_datas = device.get_service_datas();
for (const auto &service_data : service_datas) {
if (service_data.uuid.get_uuid().len != ESP_UUID_LEN_16) {
continue;
}
if (service_data.uuid.get_uuid().uuid.uuid16 != BTHOME_SERVICE_UUID) {
continue;
}
ESP_LOGV(TAG, "Found BTHome device: %s", device.address_str().c_str());
if (service_data.data.empty()) {
ESP_LOGW(TAG, "Empty service data");
return false;
}
// Copy service data to mutable vector
std::vector<uint8_t> payload(service_data.data.begin(), service_data.data.end());
// Parse device info byte
uint8_t device_info = payload[0];
bool is_encrypted = (device_info & BTHOME_ENCRYPTION_FLAG) != 0;
bool is_trigger_based = (device_info & BTHOME_TRIGGER_FLAG) != 0;
ESP_LOGD(TAG, "Device info: 0x%02X, encrypted: %d, trigger: %d", device_info, is_encrypted, is_trigger_based);
// Handle encryption
if (is_encrypted) {
uint32_t count = 0;
if (!decrypt_payload(payload, device.address_uint64(), count)) {
ESP_LOGW(TAG, "Failed to decrypt payload");
return false;
}
}
// Parse measurement data
BTHomeParseResult result;
result.has_encryption = is_encrypted;
result.is_trigger_based = is_trigger_based;
result.device_info = device_info;
if (payload.size() > 1) {
if (!parse_bthome_data_byte(&payload[1], payload.size() - 1, result)) {
ESP_LOGW(TAG, "Failed to parse BTHome data");
return false;
}
}
ESP_LOGD(TAG, "Parsed %d sensors and %d binary sensors", result.sensors.size(), result.binary_sensors.size());
// Result will be processed by child sensor/binary_sensor components
return true;
}
return false;
}
} // namespace bthome_ble
} // namespace esphome
#endif

View File

@@ -0,0 +1,115 @@
#pragma once
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include <map>
#include <vector>
#ifdef USE_ESP32
namespace esphome {
namespace bthome_ble {
// BTHome object IDs - subset of supported sensor types
enum BTHomeObjectId : uint8_t {
PACKET_ID = 0x00,
BATTERY = 0x01,
TEMPERATURE = 0x02,
HUMIDITY = 0x03,
PRESSURE = 0x04,
ILLUMINANCE = 0x05,
MASS_KG = 0x06,
MASS_LB = 0x07,
DEWPOINT = 0x08,
COUNT = 0x09,
ENERGY = 0x0A,
POWER = 0x0B,
VOLTAGE = 0x0C,
PM25 = 0x0D,
PM10 = 0x0E,
CO2 = 0x12,
VOC = 0x13,
MOISTURE = 0x14,
BATTERY_LOW = 0x15,
BATTERY_CHARGING = 0x16,
CARBON_MONOXIDE = 0x17,
COLD = 0x18,
CONNECTIVITY = 0x19,
DOOR = 0x1A,
GARAGE_DOOR = 0x1B,
GAS = 0x1C,
HEAT = 0x1D,
LIGHT = 0x1E,
LOCK = 0x1F,
MOISTURE_BOOL = 0x20,
MOTION = 0x21,
MOVING = 0x22,
OCCUPANCY = 0x23,
OPENING = 0x24,
PLUG = 0x25,
POWER_ON = 0x26,
PRESENCE = 0x27,
PROBLEM = 0x28,
RUNNING = 0x29,
SAFETY = 0x2A,
SMOKE = 0x2B,
SOUND = 0x2C,
TAMPER = 0x2D,
VIBRATION = 0x2E,
WINDOW = 0x2F,
HUMIDITY_PERCENT = 0x2E,
MOISTURE_PERCENT = 0x2F,
BUTTON = 0x3A,
DIMMER = 0x3C,
COUNT_UINT16 = 0x3D,
COUNT_UINT32 = 0x3E,
ROTATION = 0x3F,
DISTANCE_MM = 0x40,
DISTANCE_M = 0x41,
DURATION = 0x42,
CURRENT = 0x43,
SPEED = 0x44,
TEMPERATURE_PRECISE = 0x45,
UV_INDEX = 0x46,
VOLUME_L = 0x47,
VOLUME_ML = 0x48,
VOLUME_FLOW = 0x49,
VOLTAGE_PRECISE = 0x4A,
GAS_VOLUME = 0x4B,
GAS_VOLUME_L = 0x4C,
ENERGY_PRECISE = 0x4D,
VOLUME_PRECISE = 0x4E,
WATER = 0x4F,
TIMESTAMP = 0x50,
ACCELERATION = 0x51,
GYROSCOPE = 0x52,
TEXT = 0x53,
RAW = 0x54,
};
struct BTHomeParseResult {
std::map<uint8_t, float> sensors;
std::map<uint8_t, bool> binary_sensors;
std::map<uint8_t, std::string> text_sensors;
bool has_encryption{false};
bool is_trigger_based{false};
uint8_t device_info{0};
};
class BTHomeListener : public esp32_ble_tracker::ESPBTDeviceListener {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
void set_bindkey(const std::string &bindkey);
bool decrypt_payload(std::vector<uint8_t> &payload, const uint64_t &address, const uint32_t &count);
protected:
optional<std::string> bindkey_;
};
bool parse_bthome_data_byte(const uint8_t *data, uint8_t data_length, BTHomeParseResult &result);
} // namespace bthome_ble
} // namespace esphome
#endif

View File

@@ -0,0 +1,256 @@
import esphome.codegen as cg
from esphome.components import sensor, esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import (
CONF_BINDKEY,
CONF_ID,
CONF_TYPE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CO2,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_PM10,
DEVICE_CLASS_PM25,
DEVICE_CLASS_POWER,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_EMPTY,
UNIT_KILOGRAM,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT_HOURS,
UNIT_LUX,
UNIT_METER,
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_MILLIGRAM_PER_CUBIC_METER,
UNIT_MILLIMETER,
UNIT_PARTS_PER_BILLION,
UNIT_PARTS_PER_MILLION,
UNIT_PASCAL,
UNIT_PERCENT,
UNIT_VOLT,
UNIT_WATT,
)
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["bthome_ble"]
bthome_ble_ns = cg.esphome_ns.namespace("bthome_ble")
BTHomeSensor = bthome_ble_ns.class_(
"BTHomeSensor", sensor.Sensor, esp32_ble_tracker.ESPBTDeviceListener
)
# BTHome object ID type mapping
SENSOR_TYPES = {
"packet_id": 0x00,
"battery": 0x01,
"temperature": 0x02,
"humidity": 0x03,
"pressure": 0x04,
"illuminance": 0x05,
"mass_kg": 0x06,
"mass_lb": 0x07,
"dewpoint": 0x08,
"count": 0x09,
"energy": 0x0A,
"power": 0x0B,
"voltage": 0x0C,
"pm25": 0x0D,
"pm10": 0x0E,
"co2": 0x12,
"voc": 0x13,
"moisture": 0x14,
"count_uint16": 0x3D,
"count_uint32": 0x3E,
"rotation": 0x3F,
"distance_mm": 0x40,
"distance_m": 0x41,
"duration": 0x42,
"current": 0x43,
"speed": 0x44,
"temperature_precise": 0x45,
"uv_index": 0x46,
"volume_l": 0x47,
"volume_ml": 0x48,
"volume_flow": 0x49,
"voltage_precise": 0x4A,
"gas_volume": 0x4B,
"gas_volume_l": 0x4C,
"energy_precise": 0x4D,
"volume_precise": 0x4E,
"water": 0x4F,
"timestamp": 0x50,
"acceleration": 0x51,
"gyroscope": 0x52,
}
# Default configurations for sensor types
SENSOR_DEFAULTS = {
"battery": {
"unit_of_measurement": UNIT_PERCENT,
"accuracy_decimals": 0,
"device_class": DEVICE_CLASS_BATTERY,
"state_class": STATE_CLASS_MEASUREMENT,
},
"temperature": {
"unit_of_measurement": UNIT_CELSIUS,
"accuracy_decimals": 2,
"device_class": DEVICE_CLASS_TEMPERATURE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"temperature_precise": {
"unit_of_measurement": UNIT_CELSIUS,
"accuracy_decimals": 1,
"device_class": DEVICE_CLASS_TEMPERATURE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"humidity": {
"unit_of_measurement": UNIT_PERCENT,
"accuracy_decimals": 2,
"device_class": DEVICE_CLASS_HUMIDITY,
"state_class": STATE_CLASS_MEASUREMENT,
},
"pressure": {
"unit_of_measurement": UNIT_PASCAL,
"accuracy_decimals": 2,
"device_class": DEVICE_CLASS_PRESSURE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"illuminance": {
"unit_of_measurement": UNIT_LUX,
"accuracy_decimals": 2,
"device_class": DEVICE_CLASS_ILLUMINANCE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"mass_kg": {
"unit_of_measurement": UNIT_KILOGRAM,
"accuracy_decimals": 2,
"state_class": STATE_CLASS_MEASUREMENT,
},
"mass_lb": {
"unit_of_measurement": "lb",
"accuracy_decimals": 2,
"state_class": STATE_CLASS_MEASUREMENT,
},
"dewpoint": {
"unit_of_measurement": UNIT_CELSIUS,
"accuracy_decimals": 2,
"device_class": DEVICE_CLASS_TEMPERATURE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"count": {
"accuracy_decimals": 0,
"state_class": STATE_CLASS_TOTAL_INCREASING,
},
"energy": {
"unit_of_measurement": UNIT_KILOWATT_HOURS,
"accuracy_decimals": 3,
"device_class": DEVICE_CLASS_ENERGY,
"state_class": STATE_CLASS_TOTAL_INCREASING,
},
"power": {
"unit_of_measurement": UNIT_WATT,
"accuracy_decimals": 2,
"device_class": DEVICE_CLASS_POWER,
"state_class": STATE_CLASS_MEASUREMENT,
},
"voltage": {
"unit_of_measurement": UNIT_VOLT,
"accuracy_decimals": 3,
"device_class": DEVICE_CLASS_VOLTAGE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"voltage_precise": {
"unit_of_measurement": UNIT_VOLT,
"accuracy_decimals": 1,
"device_class": DEVICE_CLASS_VOLTAGE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"pm25": {
"unit_of_measurement": UNIT_MICROGRAMS_PER_CUBIC_METER,
"accuracy_decimals": 0,
"device_class": DEVICE_CLASS_PM25,
"state_class": STATE_CLASS_MEASUREMENT,
},
"pm10": {
"unit_of_measurement": UNIT_MICROGRAMS_PER_CUBIC_METER,
"accuracy_decimals": 0,
"device_class": DEVICE_CLASS_PM10,
"state_class": STATE_CLASS_MEASUREMENT,
},
"co2": {
"unit_of_measurement": UNIT_PARTS_PER_MILLION,
"accuracy_decimals": 0,
"device_class": DEVICE_CLASS_CO2,
"state_class": STATE_CLASS_MEASUREMENT,
},
"voc": {
"unit_of_measurement": UNIT_MICROGRAMS_PER_CUBIC_METER,
"accuracy_decimals": 0,
"state_class": STATE_CLASS_MEASUREMENT,
},
"moisture": {
"unit_of_measurement": UNIT_PERCENT,
"accuracy_decimals": 2,
"device_class": DEVICE_CLASS_MOISTURE,
"state_class": STATE_CLASS_MEASUREMENT,
},
"current": {
"unit_of_measurement": UNIT_AMPERE,
"accuracy_decimals": 3,
"device_class": DEVICE_CLASS_CURRENT,
"state_class": STATE_CLASS_MEASUREMENT,
},
"distance_mm": {
"unit_of_measurement": UNIT_MILLIMETER,
"accuracy_decimals": 0,
"state_class": STATE_CLASS_MEASUREMENT,
},
"distance_m": {
"unit_of_measurement": UNIT_METER,
"accuracy_decimals": 1,
"state_class": STATE_CLASS_MEASUREMENT,
},
}
def apply_defaults(config):
"""Apply default configurations based on sensor type"""
sensor_type = config[CONF_TYPE]
if sensor_type in SENSOR_DEFAULTS:
defaults = SENSOR_DEFAULTS[sensor_type]
for key, value in defaults.items():
if key not in config:
config[key] = value
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(BTHomeSensor)
.extend(
{
cv.Required(CONF_TYPE): cv.enum(SENSOR_TYPES, lower=True),
cv.Optional(CONF_BINDKEY): cv.bind_key,
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
apply_defaults,
)
async def to_code(config):
var = await sensor.new_sensor(config)
await esp32_ble_tracker.register_ble_device(var, config)
object_id = SENSOR_TYPES[config[CONF_TYPE]]
cg.add(var.set_object_id(object_id))
if bindkey := config.get(CONF_BINDKEY):
cg.add(var.set_bindkey(bindkey))

View File

@@ -0,0 +1,153 @@
#include "bthome_sensor.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include <mbedtls/ccm.h>
namespace esphome {
namespace bthome_ble {
static const char *const TAG = "bthome_ble.sensor";
// BTHome v2 service UUID
static const uint16_t BTHOME_SERVICE_UUID = 0xFCD2;
// Device info byte masks
static const uint8_t BTHOME_ENCRYPTION_FLAG = 0x01;
void BTHomeSensor::set_bindkey(const std::string &bindkey) {
if (bindkey.length() != 32) {
ESP_LOGE(TAG, "Bindkey must be 32 characters (16 bytes)");
return;
}
this->bindkey_ = bindkey;
}
bool BTHomeSensor::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// Look for BTHome service data
const auto &service_datas = device.get_service_datas();
for (const auto &service_data : service_datas) {
if (service_data.uuid.get_uuid().len != ESP_UUID_LEN_16) {
continue;
}
if (service_data.uuid.get_uuid().uuid.uuid16 != BTHOME_SERVICE_UUID) {
continue;
}
ESP_LOGVV(TAG, "Found BTHome device: %s for sensor object_id 0x%02X", device.address_str().c_str(),
this->object_id_);
if (service_data.data.empty()) {
ESP_LOGW(TAG, "Empty service data");
return false;
}
// Copy service data to mutable vector
std::vector<uint8_t> payload(service_data.data.begin(), service_data.data.end());
// Parse device info byte
uint8_t device_info = payload[0];
bool is_encrypted = (device_info & BTHOME_ENCRYPTION_FLAG) != 0;
// Handle encryption
if (is_encrypted) {
if (!this->bindkey_.has_value()) {
ESP_LOGW(TAG, "Encrypted payload but no bindkey configured");
return false;
}
// Convert bindkey from hex string to bytes
uint8_t key[16];
for (int i = 0; i < 16; i++) {
key[i] = (uint8_t) strtol(this->bindkey_.value().substr(i * 2, 2).c_str(), nullptr, 16);
}
// Build nonce: address (6 bytes) + UUID (2 bytes) + device info (1 byte) + counter (4 bytes)
uint8_t nonce[13];
uint64_t address = device.address_uint64();
nonce[0] = (address >> 40) & 0xFF;
nonce[1] = (address >> 32) & 0xFF;
nonce[2] = (address >> 24) & 0xFF;
nonce[3] = (address >> 16) & 0xFF;
nonce[4] = (address >> 8) & 0xFF;
nonce[5] = address & 0xFF;
nonce[6] = 0xD2; // UUID LSB
nonce[7] = 0xFC; // UUID MSB
nonce[8] = device_info;
// Extract packet ID as counter if present
uint32_t count = 0;
if (payload.size() > 6 && payload[1] == 0x00) { // Packet ID present
count = payload[2];
}
nonce[9] = (count >> 24) & 0xFF;
nonce[10] = (count >> 16) & 0xFF;
nonce[11] = (count >> 8) & 0xFF;
nonce[12] = count & 0xFF;
// Tag is last 4 bytes of payload
if (payload.size() < 5) {
ESP_LOGW(TAG, "Encrypted payload too short");
return false;
}
uint8_t tag[4];
size_t ciphertext_len = payload.size() - 5;
memcpy(tag, &payload[payload.size() - 4], 4);
// Decrypt using AES-CCM
mbedtls_ccm_context ctx;
mbedtls_ccm_init(&ctx);
int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, key, 128);
if (ret != 0) {
ESP_LOGW(TAG, "Failed to set decryption key: %d", ret);
mbedtls_ccm_free(&ctx);
return false;
}
std::vector<uint8_t> plaintext(ciphertext_len);
ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_len, nonce, 13, nullptr, 0, &payload[1], plaintext.data(), tag,
4);
mbedtls_ccm_free(&ctx);
if (ret != 0) {
ESP_LOGVV(TAG, "Failed to decrypt payload: %d", ret);
return false;
}
// Replace encrypted data with decrypted data
payload.resize(1 + ciphertext_len);
memcpy(&payload[1], plaintext.data(), ciphertext_len);
}
// Parse measurement data looking for our object ID
BTHomeParseResult result;
if (payload.size() > 1) {
if (!parse_bthome_data_byte(&payload[1], payload.size() - 1, result)) {
ESP_LOGW(TAG, "Failed to parse BTHome data");
return false;
}
// Check if our sensor object ID is present
auto it = result.sensors.find(this->object_id_);
if (it != result.sensors.end()) {
float value = it->second;
ESP_LOGD(TAG, "Sensor 0x%02X: %.2f", this->object_id_, value);
this->publish_state(value);
return true;
}
}
}
return false;
}
} // namespace bthome_ble
} // namespace esphome
#endif

View File

@@ -0,0 +1,25 @@
#pragma once
#include "../bthome_ble.h"
#include "esphome/components/sensor/sensor.h"
#ifdef USE_ESP32
namespace esphome {
namespace bthome_ble {
class BTHomeSensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener {
public:
void set_object_id(uint8_t object_id) { this->object_id_ = object_id; }
void set_bindkey(const std::string &bindkey);
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
protected:
uint8_t object_id_{0};
optional<std::string> bindkey_;
};
} // namespace bthome_ble
} // namespace esphome
#endif

View File

@@ -0,0 +1,141 @@
esp32_ble_tracker:
scan_parameters:
interval: 1100ms
window: 1100ms
active: true
sensor:
# Temperature sensor without encryption
- platform: bthome_ble
mac_address: "A4:C1:38:12:34:56"
type: temperature
name: "BTHome Temperature"
# Humidity sensor without encryption
- platform: bthome_ble
mac_address: "A4:C1:38:12:34:56"
type: humidity
name: "BTHome Humidity"
# Battery sensor with encryption
- platform: bthome_ble
mac_address: "A4:C1:38:78:90:AB"
bindkey: "231d39c1d7cc1ab1aee224cd096db932"
type: battery
name: "BTHome Battery Encrypted"
# Pressure sensor
- platform: bthome_ble
mac_address: "A4:C1:38:12:34:56"
type: pressure
name: "BTHome Pressure"
# Illuminance sensor
- platform: bthome_ble
mac_address: "A4:C1:38:12:34:56"
type: illuminance
name: "BTHome Illuminance"
# Power sensor
- platform: bthome_ble
mac_address: "A4:C1:38:CD:EF:12"
type: power
name: "BTHome Power"
# Energy sensor
- platform: bthome_ble
mac_address: "A4:C1:38:CD:EF:12"
type: energy
name: "BTHome Energy"
# Voltage sensor
- platform: bthome_ble
mac_address: "A4:C1:38:CD:EF:12"
type: voltage
name: "BTHome Voltage"
# Current sensor
- platform: bthome_ble
mac_address: "A4:C1:38:CD:EF:12"
type: current
name: "BTHome Current"
# PM2.5 sensor
- platform: bthome_ble
mac_address: "A4:C1:38:34:56:78"
type: pm25
name: "BTHome PM2.5"
# PM10 sensor
- platform: bthome_ble
mac_address: "A4:C1:38:34:56:78"
type: pm10
name: "BTHome PM10"
# CO2 sensor
- platform: bthome_ble
mac_address: "A4:C1:38:34:56:78"
type: co2
name: "BTHome CO2"
# VOC sensor
- platform: bthome_ble
mac_address: "A4:C1:38:34:56:78"
type: voc
name: "BTHome VOC"
# Moisture sensor
- platform: bthome_ble
mac_address: "A4:C1:38:34:56:78"
type: moisture
name: "BTHome Moisture"
binary_sensor:
# Motion sensor without encryption
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: motion
name: "BTHome Motion"
# Door sensor with encryption
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
bindkey: "231d39c1d7cc1ab1aee224cd096db932"
type: door
name: "BTHome Door Encrypted"
# Window sensor
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: window
name: "BTHome Window"
# Battery low sensor
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: battery_low
name: "BTHome Battery Low"
# Occupancy sensor
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: occupancy
name: "BTHome Occupancy"
# Smoke sensor
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: smoke
name: "BTHome Smoke"
# Moisture binary sensor
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: moisture
name: "BTHome Moisture Binary"
# Opening sensor
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: opening
name: "BTHome Opening"

View File

@@ -0,0 +1,34 @@
esp32_ble_tracker:
scan_parameters:
interval: 1100ms
window: 1100ms
active: true
sensor:
- platform: bthome_ble
mac_address: "A4:C1:38:12:34:56"
type: temperature
name: "BTHome Temperature"
- platform: bthome_ble
mac_address: "A4:C1:38:12:34:56"
type: humidity
name: "BTHome Humidity"
- platform: bthome_ble
mac_address: "A4:C1:38:78:90:AB"
bindkey: "231d39c1d7cc1ab1aee224cd096db932"
type: battery
name: "BTHome Battery"
binary_sensor:
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
type: motion
name: "BTHome Motion"
- platform: bthome_ble
mac_address: "A4:C1:38:9A:BC:DE"
bindkey: "231d39c1d7cc1ab1aee224cd096db932"
type: door
name: "BTHome Door"