diff --git a/esphome/components/bthome_ble/__init__.py b/esphome/components/bthome_ble/__init__.py new file mode 100644 index 0000000000..34867b24e7 --- /dev/null +++ b/esphome/components/bthome_ble/__init__.py @@ -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)) diff --git a/esphome/components/bthome_ble/binary_sensor/__init__.py b/esphome/components/bthome_ble/binary_sensor/__init__.py new file mode 100644 index 0000000000..c9f6545b91 --- /dev/null +++ b/esphome/components/bthome_ble/binary_sensor/__init__.py @@ -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)) diff --git a/esphome/components/bthome_ble/binary_sensor/bthome_binary_sensor.cpp b/esphome/components/bthome_ble/binary_sensor/bthome_binary_sensor.cpp new file mode 100644 index 0000000000..b6b9394f84 --- /dev/null +++ b/esphome/components/bthome_ble/binary_sensor/bthome_binary_sensor.cpp @@ -0,0 +1,153 @@ +#include "bthome_binary_sensor.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +#include + +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 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 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 diff --git a/esphome/components/bthome_ble/binary_sensor/bthome_binary_sensor.h b/esphome/components/bthome_ble/binary_sensor/bthome_binary_sensor.h new file mode 100644 index 0000000000..ccf82a3a48 --- /dev/null +++ b/esphome/components/bthome_ble/binary_sensor/bthome_binary_sensor.h @@ -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 bindkey_; +}; + +} // namespace bthome_ble +} // namespace esphome + +#endif diff --git a/esphome/components/bthome_ble/bthome_ble.cpp b/esphome/components/bthome_ble/bthome_ble.cpp new file mode 100644 index 0000000000..4326e01bbf --- /dev/null +++ b/esphome/components/bthome_ble/bthome_ble.cpp @@ -0,0 +1,329 @@ +#include "bthome_ble.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +#include + +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 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 &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 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 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 diff --git a/esphome/components/bthome_ble/bthome_ble.h b/esphome/components/bthome_ble/bthome_ble.h new file mode 100644 index 0000000000..4157d55996 --- /dev/null +++ b/esphome/components/bthome_ble/bthome_ble.h @@ -0,0 +1,115 @@ +#pragma once + +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/component.h" + +#include +#include + +#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 sensors; + std::map binary_sensors; + std::map 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 &payload, const uint64_t &address, const uint32_t &count); + + protected: + optional bindkey_; +}; + +bool parse_bthome_data_byte(const uint8_t *data, uint8_t data_length, BTHomeParseResult &result); + +} // namespace bthome_ble +} // namespace esphome + +#endif diff --git a/esphome/components/bthome_ble/sensor/__init__.py b/esphome/components/bthome_ble/sensor/__init__.py new file mode 100644 index 0000000000..dec5ce2afa --- /dev/null +++ b/esphome/components/bthome_ble/sensor/__init__.py @@ -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)) diff --git a/esphome/components/bthome_ble/sensor/bthome_sensor.cpp b/esphome/components/bthome_ble/sensor/bthome_sensor.cpp new file mode 100644 index 0000000000..a3741e9b72 --- /dev/null +++ b/esphome/components/bthome_ble/sensor/bthome_sensor.cpp @@ -0,0 +1,153 @@ +#include "bthome_sensor.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +#include + +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 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 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 diff --git a/esphome/components/bthome_ble/sensor/bthome_sensor.h b/esphome/components/bthome_ble/sensor/bthome_sensor.h new file mode 100644 index 0000000000..6dfcb6a9ed --- /dev/null +++ b/esphome/components/bthome_ble/sensor/bthome_sensor.h @@ -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 bindkey_; +}; + +} // namespace bthome_ble +} // namespace esphome + +#endif diff --git a/tests/components/bthome_ble/test.esp32-idf.yaml b/tests/components/bthome_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..20111acb57 --- /dev/null +++ b/tests/components/bthome_ble/test.esp32-idf.yaml @@ -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" diff --git a/tests/components/bthome_ble/test.esp32.yaml b/tests/components/bthome_ble/test.esp32.yaml new file mode 100644 index 0000000000..7821c70533 --- /dev/null +++ b/tests/components/bthome_ble/test.esp32.yaml @@ -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"