mirror of
https://github.com/esphome/esphome.git
synced 2026-01-10 04:00:51 -07:00
[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:
27
esphome/components/bthome_ble/__init__.py
Normal file
27
esphome/components/bthome_ble/__init__.py
Normal 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))
|
||||
138
esphome/components/bthome_ble/binary_sensor/__init__.py
Normal file
138
esphome/components/bthome_ble/binary_sensor/__init__.py
Normal 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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
329
esphome/components/bthome_ble/bthome_ble.cpp
Normal file
329
esphome/components/bthome_ble/bthome_ble.cpp
Normal 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
|
||||
115
esphome/components/bthome_ble/bthome_ble.h
Normal file
115
esphome/components/bthome_ble/bthome_ble.h
Normal 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
|
||||
256
esphome/components/bthome_ble/sensor/__init__.py
Normal file
256
esphome/components/bthome_ble/sensor/__init__.py
Normal 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))
|
||||
153
esphome/components/bthome_ble/sensor/bthome_sensor.cpp
Normal file
153
esphome/components/bthome_ble/sensor/bthome_sensor.cpp
Normal 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
|
||||
25
esphome/components/bthome_ble/sensor/bthome_sensor.h
Normal file
25
esphome/components/bthome_ble/sensor/bthome_sensor.h
Normal 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
|
||||
141
tests/components/bthome_ble/test.esp32-idf.yaml
Normal file
141
tests/components/bthome_ble/test.esp32-idf.yaml
Normal 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"
|
||||
34
tests/components/bthome_ble/test.esp32.yaml
Normal file
34
tests/components/bthome_ble/test.esp32.yaml
Normal 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"
|
||||
Reference in New Issue
Block a user