mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 15:30:21 +00:00
[bthome] Add BTHome v2 BLE protocol support
Implements native BTHome v2 protocol support for broadcasting sensor data over BLE advertisements. This enables ESP32 devices to act as battery-powered BLE sensors that are natively compatible with Home Assistant. Features: - Support for multiple sensor types (temperature, humidity, battery, etc.) - Support for binary sensors (motion, door, window, etc.) - AES-CCM encryption support with 16-byte keys - Automatic packet encoding per BTHome v2 specification - Dynamic advertisement updates when sensor values change - Configurable advertising intervals and TX power - Proper BTHome v2 device info byte and service UUID Implementation: - Python configuration for sensor/binary sensor mapping - C++ component for BTHome packet encoding and BLE advertising - mbedtls CCM encryption for secure advertisements - Leverages existing esp32_ble infrastructure Test coverage: - Basic sensor/binary sensor configuration tests - Encryption test configuration - Multi-platform support (ESP32, ESP32-C3, ESP32-S3 with IDF) Closes: https://github.com/orgs/esphome/discussions/3235
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, esp32_ble, sensor
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.esp32_ble import CONF_BLE_ID
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_BATTERY_LEVEL,
|
||||
CONF_HUMIDITY,
|
||||
CONF_ILLUMINANCE,
|
||||
CONF_PRESSURE,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TX_POWER,
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
|
||||
AUTO_LOAD = ["esp32_ble"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
bthome_ns = cg.esphome_ns.namespace("bthome")
|
||||
BTHome = bthome_ns.class_(
|
||||
"BTHome",
|
||||
cg.Component,
|
||||
esp32_ble.GAPEventHandler,
|
||||
cg.Parented.template(esp32_ble.ESP32BLE),
|
||||
)
|
||||
|
||||
# Configuration constants
|
||||
CONF_MEASUREMENT = "measurement"
|
||||
CONF_ENCRYPTION_KEY = "encryption_key"
|
||||
CONF_MIN_INTERVAL = "min_interval"
|
||||
CONF_MAX_INTERVAL = "max_interval"
|
||||
CONF_SENSOR_TYPE = "type"
|
||||
|
||||
# Sensor type constants
|
||||
CONF_BATTERY = "battery"
|
||||
CONF_CO2 = "co2"
|
||||
CONF_COUNT = "count"
|
||||
CONF_CURRENT = "current"
|
||||
CONF_DEWPOINT = "dewpoint"
|
||||
CONF_DISTANCE = "distance"
|
||||
CONF_ENERGY = "energy"
|
||||
CONF_GAS = "gas"
|
||||
CONF_MASS = "mass"
|
||||
CONF_MOISTURE = "moisture"
|
||||
CONF_PM25 = "pm2_5"
|
||||
CONF_PM10 = "pm10"
|
||||
CONF_POWER = "power"
|
||||
CONF_ROTATION = "rotation"
|
||||
CONF_SPEED = "speed"
|
||||
CONF_TIMESTAMP = "timestamp"
|
||||
CONF_TVOC = "tvoc"
|
||||
CONF_VOLTAGE = "voltage"
|
||||
CONF_VOLUME = "volume"
|
||||
|
||||
# Binary sensor types
|
||||
CONF_BINARY_SENSOR = "binary_sensor"
|
||||
CONF_BATTERY_LOW = "battery_low"
|
||||
CONF_BATTERY_CHARGING = "battery_charging"
|
||||
CONF_CO = "carbon_monoxide"
|
||||
CONF_COLD = "cold"
|
||||
CONF_CONNECTIVITY = "connectivity"
|
||||
CONF_DOOR = "door"
|
||||
CONF_GARAGE_DOOR = "garage_door"
|
||||
CONF_GAS_DETECTED = "gas"
|
||||
CONF_GENERIC_BOOLEAN = "generic_boolean"
|
||||
CONF_HEAT = "heat"
|
||||
CONF_LIGHT = "light"
|
||||
CONF_LOCK = "lock"
|
||||
CONF_MOISTURE_DETECTED = "moisture"
|
||||
CONF_MOTION = "motion"
|
||||
CONF_MOVING = "moving"
|
||||
CONF_OCCUPANCY = "occupancy"
|
||||
CONF_OPENING = "opening"
|
||||
CONF_PLUG = "plug"
|
||||
CONF_POWER_ON = "power"
|
||||
CONF_PRESENCE = "presence"
|
||||
CONF_PROBLEM = "problem"
|
||||
CONF_RUNNING = "running"
|
||||
CONF_SAFETY = "safety"
|
||||
CONF_SMOKE = "smoke"
|
||||
CONF_SOUND = "sound"
|
||||
CONF_TAMPER = "tamper"
|
||||
CONF_VIBRATION = "vibration"
|
||||
CONF_WINDOW = "window"
|
||||
|
||||
# BTHome object IDs for sensors
|
||||
SENSOR_OBJECT_IDS = {
|
||||
CONF_BATTERY: 0x01,
|
||||
CONF_TEMPERATURE: 0x02, # 0.01°C
|
||||
CONF_HUMIDITY: 0x03,
|
||||
CONF_PRESSURE: 0x04,
|
||||
CONF_ILLUMINANCE: 0x05,
|
||||
CONF_MASS: 0x06,
|
||||
CONF_DEWPOINT: 0x08,
|
||||
CONF_ENERGY: 0x0A,
|
||||
CONF_POWER: 0x0B,
|
||||
CONF_VOLTAGE: 0x0C,
|
||||
CONF_PM25: 0x0D,
|
||||
CONF_PM10: 0x0E,
|
||||
CONF_CO2: 0x12,
|
||||
CONF_TVOC: 0x13,
|
||||
CONF_MOISTURE: 0x14,
|
||||
CONF_CURRENT: 0x43,
|
||||
CONF_SPEED: 0x44,
|
||||
CONF_TIMESTAMP: 0x50,
|
||||
}
|
||||
|
||||
# BTHome object IDs for binary sensors
|
||||
BINARY_SENSOR_OBJECT_IDS = {
|
||||
CONF_GENERIC_BOOLEAN: 0x0F,
|
||||
CONF_POWER_ON: 0x10,
|
||||
CONF_BATTERY_LOW: 0x15,
|
||||
CONF_BATTERY_CHARGING: 0x16,
|
||||
CONF_CO: 0x17,
|
||||
CONF_COLD: 0x18,
|
||||
CONF_DOOR: 0x1A,
|
||||
CONF_GARAGE_DOOR: 0x1B,
|
||||
CONF_LIGHT: 0x1E,
|
||||
CONF_LOCK: 0x1F,
|
||||
CONF_MOTION: 0x21,
|
||||
CONF_OCCUPANCY: 0x23,
|
||||
CONF_SMOKE: 0x29,
|
||||
CONF_WINDOW: 0x2D,
|
||||
}
|
||||
|
||||
|
||||
def validate_config(config):
|
||||
if config[CONF_MIN_INTERVAL] > config.get(CONF_MAX_INTERVAL):
|
||||
raise cv.Invalid("min_interval must be <= max_interval")
|
||||
return config
|
||||
|
||||
|
||||
def validate_encryption_key(value):
|
||||
value = cv.string_strict(value)
|
||||
# Remove any spaces or dashes
|
||||
value = value.replace(" ", "").replace("-", "")
|
||||
# Must be 32 hex characters (16 bytes)
|
||||
if len(value) != 32:
|
||||
raise cv.Invalid("Encryption key must be 32 hexadecimal characters (16 bytes)")
|
||||
try:
|
||||
int(value, 16)
|
||||
except ValueError as e:
|
||||
raise cv.Invalid("Encryption key must be valid hexadecimal") from e
|
||||
return value.lower()
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BTHome),
|
||||
cv.GenerateID(CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE),
|
||||
cv.Optional(CONF_MIN_INTERVAL, default="1s"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(
|
||||
min=TimePeriod(milliseconds=20), max=TimePeriod(milliseconds=10240)
|
||||
),
|
||||
),
|
||||
cv.Optional(CONF_MAX_INTERVAL, default="1s"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(
|
||||
min=TimePeriod(milliseconds=20), max=TimePeriod(milliseconds=10240)
|
||||
),
|
||||
),
|
||||
cv.Optional(CONF_TX_POWER, default="3dBm"): cv.All(
|
||||
cv.decibel, cv.enum(esp32_ble.TX_POWER_LEVELS, int=True)
|
||||
),
|
||||
cv.Optional(CONF_ENCRYPTION_KEY): validate_encryption_key,
|
||||
cv.Optional(CONF_MEASUREMENT): cv.ensure_list(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_SENSOR_TYPE): cv.one_of(
|
||||
*SENSOR_OBJECT_IDS.keys(), lower=True
|
||||
),
|
||||
cv.Required(CONF_ID): cv.use_id(sensor.Sensor),
|
||||
}
|
||||
)
|
||||
),
|
||||
cv.Optional(CONF_BINARY_SENSOR): cv.ensure_list(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_SENSOR_TYPE): cv.one_of(
|
||||
*BINARY_SENSOR_OBJECT_IDS.keys(), lower=True
|
||||
),
|
||||
cv.Required(CONF_ID): cv.use_id(binary_sensor.BinarySensor),
|
||||
}
|
||||
)
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
validate_config,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
parent = await cg.get_variable(config[esp32_ble.CONF_BLE_ID])
|
||||
esp32_ble.register_gap_event_handler(parent, var)
|
||||
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_min_interval(config[CONF_MIN_INTERVAL]))
|
||||
cg.add(var.set_max_interval(config[CONF_MAX_INTERVAL]))
|
||||
cg.add(var.set_tx_power(config[CONF_TX_POWER]))
|
||||
|
||||
if CONF_ENCRYPTION_KEY in config:
|
||||
key = config[CONF_ENCRYPTION_KEY]
|
||||
key_bytes = [int(key[i : i + 2], 16) for i in range(0, len(key), 2)]
|
||||
cg.add(var.set_encryption_key(key_bytes))
|
||||
|
||||
# Add sensor measurements
|
||||
if CONF_MEASUREMENT in config:
|
||||
for measurement in config[CONF_MEASUREMENT]:
|
||||
sensor_type = measurement[CONF_SENSOR_TYPE]
|
||||
object_id = SENSOR_OBJECT_IDS[sensor_type]
|
||||
sens = await cg.get_variable(measurement[CONF_ID])
|
||||
cg.add(var.add_measurement(sens, object_id))
|
||||
|
||||
# Add binary sensor measurements
|
||||
if CONF_BINARY_SENSOR in config:
|
||||
for measurement in config[CONF_BINARY_SENSOR]:
|
||||
sensor_type = measurement[CONF_SENSOR_TYPE]
|
||||
object_id = BINARY_SENSOR_OBJECT_IDS[sensor_type]
|
||||
sens = await cg.get_variable(measurement[CONF_ID])
|
||||
cg.add(var.add_binary_measurement(sens, object_id))
|
||||
|
||||
cg.add_define("USE_ESP32_BLE_ADVERTISING")
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True)
|
||||
add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
|
||||
@@ -0,0 +1,365 @@
|
||||
#include "bthome.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
|
||||
#include <esp_bt.h>
|
||||
#endif
|
||||
#include <esp_bt_main.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <nvs_flash.h>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "mbedtls/ccm.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace bthome {
|
||||
|
||||
static const char *const TAG = "bthome";
|
||||
|
||||
// BTHome v2 service UUID (0xFCD2)
|
||||
static const uint16_t BTHOME_SERVICE_UUID = 0xFCD2;
|
||||
|
||||
// Device info byte for BTHome v2 (bit 5-7: version 010 = v2, bit 0: encryption)
|
||||
static const uint8_t BTHOME_DEVICE_INFO_UNENCRYPTED = 0x40; // 01000000
|
||||
static const uint8_t BTHOME_DEVICE_INFO_ENCRYPTED = 0x41; // 01000001
|
||||
|
||||
void BTHome::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "BTHome:");
|
||||
ESP_LOGCONFIG(TAG, " Min Interval: %ums", this->min_interval_);
|
||||
ESP_LOGCONFIG(TAG, " Max Interval: %ums", this->max_interval_);
|
||||
ESP_LOGCONFIG(TAG, " TX Power: %ddBm", (this->tx_power_ * 3) - 12);
|
||||
ESP_LOGCONFIG(TAG, " Encryption: %s", this->encryption_enabled_ ? "enabled" : "disabled");
|
||||
ESP_LOGCONFIG(TAG, " Sensors: %d", this->measurements_.size());
|
||||
ESP_LOGCONFIG(TAG, " Binary Sensors: %d", this->binary_measurements_.size());
|
||||
}
|
||||
|
||||
float BTHome::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void BTHome::setup() {
|
||||
this->ble_adv_params_ = {
|
||||
.adv_int_min = static_cast<uint16_t>(this->min_interval_ / 0.625f),
|
||||
.adv_int_max = static_cast<uint16_t>(this->max_interval_ / 0.625f),
|
||||
.adv_type = ADV_TYPE_NONCONN_IND,
|
||||
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
|
||||
.peer_addr = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
.peer_addr_type = BLE_ADDR_TYPE_PUBLIC,
|
||||
.channel_map = ADV_CHNL_ALL,
|
||||
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
|
||||
};
|
||||
|
||||
global_ble->advertising_register_raw_advertisement_callback([this](bool advertise) {
|
||||
this->advertising_ = advertise;
|
||||
if (advertise) {
|
||||
this->on_advertise_();
|
||||
}
|
||||
});
|
||||
|
||||
// Register callbacks for sensor state changes
|
||||
for (auto &measurement : this->measurements_) {
|
||||
measurement.sensor->add_on_state_callback([this](float) { this->data_changed_ = true; });
|
||||
}
|
||||
|
||||
for (auto &measurement : this->binary_measurements_) {
|
||||
measurement.sensor->add_on_state_callback([this](bool) { this->data_changed_ = true; });
|
||||
}
|
||||
}
|
||||
|
||||
void BTHome::loop() {
|
||||
// Rebuild advertisement if data has changed
|
||||
if (this->data_changed_ && this->advertising_) {
|
||||
this->build_advertisement_data_();
|
||||
this->on_advertise_();
|
||||
this->data_changed_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void BTHome::set_encryption_key(const std::vector<uint8_t> &key) {
|
||||
if (key.size() != 16) {
|
||||
ESP_LOGE(TAG, "Encryption key must be 16 bytes");
|
||||
return;
|
||||
}
|
||||
this->encryption_enabled_ = true;
|
||||
std::copy(key.begin(), key.end(), this->encryption_key_.begin());
|
||||
}
|
||||
|
||||
void BTHome::add_measurement(sensor::Sensor *sensor, uint8_t object_id) {
|
||||
this->measurements_.push_back({sensor, object_id});
|
||||
}
|
||||
|
||||
void BTHome::add_binary_measurement(binary_sensor::BinarySensor *sensor, uint8_t object_id) {
|
||||
this->binary_measurements_.push_back({sensor, object_id});
|
||||
}
|
||||
|
||||
void BTHome::build_advertisement_data_() {
|
||||
std::vector<uint8_t> service_data;
|
||||
|
||||
// Add BTHome service UUID (little-endian)
|
||||
service_data.push_back(BTHOME_SERVICE_UUID & 0xFF);
|
||||
service_data.push_back((BTHOME_SERVICE_UUID >> 8) & 0xFF);
|
||||
|
||||
// Add device info byte
|
||||
uint8_t device_info = this->encryption_enabled_ ? BTHOME_DEVICE_INFO_ENCRYPTED : BTHOME_DEVICE_INFO_UNENCRYPTED;
|
||||
service_data.push_back(device_info);
|
||||
|
||||
// Build measurement data
|
||||
std::vector<uint8_t> measurement_data;
|
||||
|
||||
// Add all sensor measurements
|
||||
for (const auto &measurement : this->measurements_) {
|
||||
if (measurement.sensor->has_state() && !std::isnan(measurement.sensor->state)) {
|
||||
this->encode_measurement_(measurement_data, measurement.object_id, measurement.sensor->state);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all binary sensor measurements
|
||||
for (const auto &measurement : this->binary_measurements_) {
|
||||
if (measurement.sensor->has_state()) {
|
||||
this->encode_binary_measurement_(measurement_data, measurement.object_id, measurement.sensor->state);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->encryption_enabled_) {
|
||||
// Encrypt the measurement data
|
||||
std::vector<uint8_t> ciphertext;
|
||||
if (this->encrypt_payload_(measurement_data, ciphertext)) {
|
||||
// Add ciphertext to service data
|
||||
service_data.insert(service_data.end(), ciphertext.begin(), ciphertext.end());
|
||||
|
||||
// Add counter (little-endian)
|
||||
service_data.push_back(this->counter_ & 0xFF);
|
||||
service_data.push_back((this->counter_ >> 8) & 0xFF);
|
||||
service_data.push_back((this->counter_ >> 16) & 0xFF);
|
||||
service_data.push_back((this->counter_ >> 24) & 0xFF);
|
||||
|
||||
// Increment counter for next advertisement
|
||||
this->counter_++;
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Encryption failed");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Add unencrypted measurement data
|
||||
service_data.insert(service_data.end(), measurement_data.begin(), measurement_data.end());
|
||||
}
|
||||
|
||||
// Build the complete advertisement data
|
||||
this->adv_data_.clear();
|
||||
|
||||
// Flags AD element (required): 0x020106
|
||||
this->adv_data_.push_back(0x02); // Length
|
||||
this->adv_data_.push_back(0x01); // Type: Flags
|
||||
this->adv_data_.push_back(0x06); // LE General Discoverable, BR/EDR not supported
|
||||
|
||||
// Service Data AD element
|
||||
this->adv_data_.push_back(service_data.size() + 1); // Length (data + type byte)
|
||||
this->adv_data_.push_back(0x16); // Type: Service Data
|
||||
this->adv_data_.insert(this->adv_data_.end(), service_data.begin(), service_data.end());
|
||||
}
|
||||
|
||||
void BTHome::encode_measurement_(std::vector<uint8_t> &data, uint8_t object_id, float value) {
|
||||
data.push_back(object_id);
|
||||
|
||||
// Encode based on object ID
|
||||
switch (object_id) {
|
||||
case 0x01: // battery (uint8, 1%)
|
||||
{
|
||||
uint8_t encoded = static_cast<uint8_t>(std::round(value));
|
||||
data.push_back(encoded);
|
||||
break;
|
||||
}
|
||||
case 0x02: // temperature (sint16, 0.01°C)
|
||||
case 0x08: // dewpoint (sint16, 0.01°C)
|
||||
{
|
||||
int16_t encoded = static_cast<int16_t>(std::round(value * 100.0f));
|
||||
data.push_back(encoded & 0xFF);
|
||||
data.push_back((encoded >> 8) & 0xFF);
|
||||
break;
|
||||
}
|
||||
case 0x03: // humidity (uint16, 0.01%)
|
||||
case 0x14: // moisture (uint16, 0.01%)
|
||||
{
|
||||
uint16_t encoded = static_cast<uint16_t>(std::round(value * 100.0f));
|
||||
data.push_back(encoded & 0xFF);
|
||||
data.push_back((encoded >> 8) & 0xFF);
|
||||
break;
|
||||
}
|
||||
case 0x04: // pressure (uint24, 0.01 hPa)
|
||||
case 0x05: // illuminance (uint24, 0.01 lux)
|
||||
case 0x0A: // energy (uint24, 0.001 kWh)
|
||||
case 0x0B: // power (uint24, 0.01 W)
|
||||
{
|
||||
float factor = (object_id == 0x0A) ? 1000.0f : 100.0f;
|
||||
uint32_t encoded = static_cast<uint32_t>(std::round(value * factor));
|
||||
data.push_back(encoded & 0xFF);
|
||||
data.push_back((encoded >> 8) & 0xFF);
|
||||
data.push_back((encoded >> 16) & 0xFF);
|
||||
break;
|
||||
}
|
||||
case 0x06: // mass (uint16, 0.01 kg)
|
||||
case 0x43: // current (uint16, 0.001 A)
|
||||
case 0x44: // speed (uint16, 0.01 m/s)
|
||||
{
|
||||
float factor = (object_id == 0x43) ? 1000.0f : 100.0f;
|
||||
uint16_t encoded = static_cast<uint16_t>(std::round(value * factor));
|
||||
data.push_back(encoded & 0xFF);
|
||||
data.push_back((encoded >> 8) & 0xFF);
|
||||
break;
|
||||
}
|
||||
case 0x0C: // voltage (uint16, 0.001 V)
|
||||
{
|
||||
uint16_t encoded = static_cast<uint16_t>(std::round(value * 1000.0f));
|
||||
data.push_back(encoded & 0xFF);
|
||||
data.push_back((encoded >> 8) & 0xFF);
|
||||
break;
|
||||
}
|
||||
case 0x0D: // PM2.5 (uint16, 1 µg/m³)
|
||||
case 0x0E: // PM10 (uint16, 1 µg/m³)
|
||||
case 0x12: // CO2 (uint16, 1 ppm)
|
||||
case 0x13: // TVOC (uint16, 1 µg/m³)
|
||||
{
|
||||
uint16_t encoded = static_cast<uint16_t>(std::round(value));
|
||||
data.push_back(encoded & 0xFF);
|
||||
data.push_back((encoded >> 8) & 0xFF);
|
||||
break;
|
||||
}
|
||||
case 0x50: // timestamp (uint32, seconds)
|
||||
{
|
||||
uint32_t encoded = static_cast<uint32_t>(value);
|
||||
data.push_back(encoded & 0xFF);
|
||||
data.push_back((encoded >> 8) & 0xFF);
|
||||
data.push_back((encoded >> 16) & 0xFF);
|
||||
data.push_back((encoded >> 24) & 0xFF);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ESP_LOGW(TAG, "Unsupported sensor object ID: 0x%02X", object_id);
|
||||
// Remove the object ID we just added
|
||||
data.pop_back();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void BTHome::encode_binary_measurement_(std::vector<uint8_t> &data, uint8_t object_id, bool value) {
|
||||
data.push_back(object_id);
|
||||
data.push_back(value ? 0x01 : 0x00);
|
||||
}
|
||||
|
||||
bool BTHome::encrypt_payload_(const std::vector<uint8_t> &plaintext, std::vector<uint8_t> &ciphertext) {
|
||||
if (!this->encryption_enabled_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get MAC address
|
||||
uint8_t mac[6];
|
||||
esp_read_mac(mac, ESP_MAC_BT);
|
||||
|
||||
// Build nonce according to BTHome spec:
|
||||
// MAC (6 bytes) + UUID reversed (2 bytes) + device info (1 byte) + counter (4 bytes) = 13 bytes
|
||||
uint8_t nonce[13];
|
||||
memcpy(nonce, mac, 6);
|
||||
nonce[6] = BTHOME_SERVICE_UUID & 0xFF; // UUID byte 1
|
||||
nonce[7] = (BTHOME_SERVICE_UUID >> 8) & 0xFF; // UUID byte 2 (already little-endian)
|
||||
nonce[8] = BTHOME_DEVICE_INFO_ENCRYPTED; // Device info byte
|
||||
nonce[9] = this->counter_ & 0xFF; // Counter byte 0
|
||||
nonce[10] = (this->counter_ >> 8) & 0xFF; // Counter byte 1
|
||||
nonce[11] = (this->counter_ >> 16) & 0xFF; // Counter byte 2
|
||||
nonce[12] = (this->counter_ >> 24) & 0xFF; // Counter byte 3
|
||||
|
||||
// Prepare output buffer (ciphertext + 4-byte MIC)
|
||||
ciphertext.resize(plaintext.size() + 4);
|
||||
|
||||
// Initialize mbedtls CCM context
|
||||
mbedtls_ccm_context ctx;
|
||||
mbedtls_ccm_init(&ctx);
|
||||
|
||||
// Set encryption key
|
||||
int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, this->encryption_key_.data(), 128);
|
||||
if (ret != 0) {
|
||||
ESP_LOGE(TAG, "mbedtls_ccm_setkey failed: %d", ret);
|
||||
mbedtls_ccm_free(&ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encrypt and generate tag
|
||||
// BTHome uses no additional authenticated data (AAD)
|
||||
ret = mbedtls_ccm_encrypt_and_tag(&ctx, plaintext.size(), nonce, sizeof(nonce), nullptr, 0, plaintext.data(),
|
||||
ciphertext.data(), ciphertext.data() + plaintext.size(), 4);
|
||||
|
||||
mbedtls_ccm_free(&ctx);
|
||||
|
||||
if (ret != 0) {
|
||||
ESP_LOGE(TAG, "mbedtls_ccm_encrypt_and_tag failed: %d", ret);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void BTHome::on_advertise_() {
|
||||
// Build advertisement data if needed
|
||||
if (this->data_changed_ || this->adv_data_.empty()) {
|
||||
this->build_advertisement_data_();
|
||||
this->data_changed_ = false;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Setting BLE TX power");
|
||||
esp_err_t err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_ble_tx_power_set failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Starting BTHome advertisement (%d bytes)", this->adv_data_.size());
|
||||
err = esp_ble_gap_config_adv_data_raw(this->adv_data_.data(), this->adv_data_.size());
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ble_gap_config_adv_data_raw failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void BTHome::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
if (!this->advertising_)
|
||||
return;
|
||||
|
||||
esp_err_t err;
|
||||
switch (event) {
|
||||
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: {
|
||||
err = esp_ble_gap_start_advertising(&this->ble_adv_params_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ble_gap_start_advertising failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: {
|
||||
err = param->adv_start_cmpl.status;
|
||||
if (err != ESP_BT_STATUS_SUCCESS) {
|
||||
ESP_LOGE(TAG, "BLE adv start failed: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_LOGD(TAG, "BLE advertising started successfully");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: {
|
||||
err = param->adv_stop_cmpl.status;
|
||||
if (err != ESP_BT_STATUS_SUCCESS) {
|
||||
ESP_LOGE(TAG, "BLE adv stop failed: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_LOGD(TAG, "BLE stopped advertising successfully");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bthome
|
||||
} // namespace esphome
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/components/esp32_ble/ble.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
|
||||
#include <esp_bt.h>
|
||||
#endif
|
||||
#include <esp_gap_ble_api.h>
|
||||
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome {
|
||||
namespace bthome {
|
||||
|
||||
using namespace esp32_ble;
|
||||
|
||||
struct SensorMeasurement {
|
||||
sensor::Sensor *sensor;
|
||||
uint8_t object_id;
|
||||
};
|
||||
|
||||
struct BinarySensorMeasurement {
|
||||
binary_sensor::BinarySensor *sensor;
|
||||
uint8_t object_id;
|
||||
};
|
||||
|
||||
class BTHome : public Component, public GAPEventHandler, public Parented<ESP32BLE> {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void loop() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
void set_min_interval(uint16_t val) { this->min_interval_ = val; }
|
||||
void set_max_interval(uint16_t val) { this->max_interval_ = val; }
|
||||
void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; }
|
||||
|
||||
void set_encryption_key(const std::vector<uint8_t> &key);
|
||||
void add_measurement(sensor::Sensor *sensor, uint8_t object_id);
|
||||
void add_binary_measurement(binary_sensor::BinarySensor *sensor, uint8_t object_id);
|
||||
|
||||
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
|
||||
|
||||
protected:
|
||||
void on_advertise_();
|
||||
void build_advertisement_data_();
|
||||
void encode_measurement_(std::vector<uint8_t> &data, uint8_t object_id, float value);
|
||||
void encode_binary_measurement_(std::vector<uint8_t> &data, uint8_t object_id, bool value);
|
||||
bool encrypt_payload_(const std::vector<uint8_t> &plaintext, std::vector<uint8_t> &ciphertext);
|
||||
|
||||
std::vector<SensorMeasurement> measurements_;
|
||||
std::vector<BinarySensorMeasurement> binary_measurements_;
|
||||
|
||||
uint16_t min_interval_{};
|
||||
uint16_t max_interval_{};
|
||||
esp_power_level_t tx_power_{};
|
||||
esp_ble_adv_params_t ble_adv_params_;
|
||||
bool advertising_{false};
|
||||
|
||||
// Encryption
|
||||
bool encryption_enabled_{false};
|
||||
std::array<uint8_t, 16> encryption_key_{};
|
||||
uint32_t counter_{0};
|
||||
|
||||
// Cached advertisement data
|
||||
std::vector<uint8_t> adv_data_;
|
||||
bool data_changed_{true};
|
||||
};
|
||||
|
||||
} // namespace bthome
|
||||
} // namespace esphome
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,32 @@
|
||||
sensor:
|
||||
- platform: template
|
||||
id: test_temperature
|
||||
name: "Test Temperature"
|
||||
- platform: template
|
||||
id: test_humidity
|
||||
name: "Test Humidity"
|
||||
- platform: template
|
||||
id: test_battery
|
||||
name: "Test Battery"
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
id: test_motion
|
||||
name: "Test Motion"
|
||||
- platform: template
|
||||
id: test_door
|
||||
name: "Test Door"
|
||||
|
||||
bthome:
|
||||
measurement:
|
||||
- type: temperature
|
||||
id: test_temperature
|
||||
- type: humidity
|
||||
id: test_humidity
|
||||
- type: battery
|
||||
id: test_battery
|
||||
binary_sensor:
|
||||
- type: motion
|
||||
id: test_motion
|
||||
- type: door
|
||||
id: test_door
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
sensor:
|
||||
- platform: template
|
||||
id: test_temperature
|
||||
name: "Test Temperature"
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
id: test_motion
|
||||
name: "Test Motion"
|
||||
|
||||
bthome:
|
||||
encryption_key: "231d39c1d7cc1ab1aee224cd096db932"
|
||||
measurement:
|
||||
- type: temperature
|
||||
id: test_temperature
|
||||
binary_sensor:
|
||||
- type: motion
|
||||
id: test_motion
|
||||
Reference in New Issue
Block a user