1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-18 15:55:46 +00:00

[bthome] Refactor to use FixedVector and add features

Major refactoring to address memory efficiency, advertisement cycling,
and immediate advertising support:

**Use FixedVector instead of std::vector:**
- Replace std::vector with FixedVector for measurements storage
- Initialize with exact sizes determined from configuration
- Eliminates STL reallocation overhead and reduces flash usage
- Uses runtime-sized FixedVector allocated once in setup()

**Config key changes:**
- Use `sensors` and `binary_sensors` (plurals) for consistency
- Matches ESPHome conventions for sensor arrays

**Advertisement size management and cycling:**
- Calculate max advertisement size (31 bytes total, minus overhead)
- Split measurements across multiple packets if they don't fit
- Automatically cycle through packets on each advertising interval
- Ensures all sensors get advertised even with many measurements
- Overhead: 8 bytes unencrypted, 16 bytes encrypted

**Immediate advertising support:**
- Add `advertise_immediately` option for sensors/binary_sensors
- When enabled, triggers immediate advertisement on state change
- Interrupts normal advertising cycle to send only that sensor
- Resumes normal cycle after immediate advertisement
- Perfect for motion sensors, door sensors, or critical alerts

**Implementation details:**
- Refactored encode functions to use raw pointers and calculate sizes
- Build multiple advertisement packets as needed
- Track current packet index for cycling
- Handle immediate advertising with separate packet building path
- Proper encryption handling with per-packet counters

Example configuration:
```yaml
bthome:
  sensors:
    - type: temperature
      id: room_temp
    - type: humidity
      id: room_humidity
  binary_sensors:
    - type: motion
      id: pir_sensor
      advertise_immediately: true  # Instant notification
    - type: door
      id: front_door
      advertise_immediately: true
```
This commit is contained in:
Claude
2025-11-17 22:37:55 +00:00
parent da04307240
commit f81a3a8c64
5 changed files with 374 additions and 136 deletions

View File

@@ -4,8 +4,9 @@ from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32_ble import CONF_BLE_ID from esphome.components.esp32_ble import CONF_BLE_ID
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_BINARY_SENSOR, CONF_BINARY_SENSORS,
CONF_ID, CONF_ID,
CONF_SENSORS,
CONF_TX_POWER, CONF_TX_POWER,
DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY,
DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_BATTERY_CHARGING,
@@ -52,11 +53,11 @@ BTHome = bthome_ns.class_(
) )
# Configuration constants # Configuration constants
CONF_MEASUREMENT = "measurement"
CONF_ENCRYPTION_KEY = "encryption_key" CONF_ENCRYPTION_KEY = "encryption_key"
CONF_MIN_INTERVAL = "min_interval" CONF_MIN_INTERVAL = "min_interval"
CONF_MAX_INTERVAL = "max_interval" CONF_MAX_INTERVAL = "max_interval"
CONF_SENSOR_TYPE = "type" CONF_SENSOR_TYPE = "type"
CONF_ADVERTISE_IMMEDIATELY = "advertise_immediately"
# BTHome object IDs for sensors (mapping from device class to BTHome object ID) # BTHome object IDs for sensors (mapping from device class to BTHome object ID)
SENSOR_DEVICE_CLASS_TO_OBJECT_ID = { SENSOR_DEVICE_CLASS_TO_OBJECT_ID = {
@@ -140,23 +141,25 @@ CONFIG_SCHEMA = cv.All(
cv.decibel, cv.enum(esp32_ble.TX_POWER_LEVELS, int=True) cv.decibel, cv.enum(esp32_ble.TX_POWER_LEVELS, int=True)
), ),
cv.Optional(CONF_ENCRYPTION_KEY): validate_encryption_key, cv.Optional(CONF_ENCRYPTION_KEY): validate_encryption_key,
cv.Optional(CONF_MEASUREMENT): cv.ensure_list( cv.Optional(CONF_SENSORS): cv.ensure_list(
cv.Schema( cv.Schema(
{ {
cv.Required(CONF_SENSOR_TYPE): cv.one_of( cv.Required(CONF_SENSOR_TYPE): cv.one_of(
*SENSOR_DEVICE_CLASS_TO_OBJECT_ID.keys(), lower=True *SENSOR_DEVICE_CLASS_TO_OBJECT_ID.keys(), lower=True
), ),
cv.Required(CONF_ID): cv.use_id(sensor.Sensor), cv.Required(CONF_ID): cv.use_id(sensor.Sensor),
cv.Optional(CONF_ADVERTISE_IMMEDIATELY, default=False): cv.boolean,
} }
) )
), ),
cv.Optional(CONF_BINARY_SENSOR): cv.ensure_list( cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list(
cv.Schema( cv.Schema(
{ {
cv.Required(CONF_SENSOR_TYPE): cv.one_of( cv.Required(CONF_SENSOR_TYPE): cv.one_of(
*BINARY_SENSOR_DEVICE_CLASS_TO_OBJECT_ID.keys(), lower=True *BINARY_SENSOR_DEVICE_CLASS_TO_OBJECT_ID.keys(), lower=True
), ),
cv.Required(CONF_ID): cv.use_id(binary_sensor.BinarySensor), cv.Required(CONF_ID): cv.use_id(binary_sensor.BinarySensor),
cv.Optional(CONF_ADVERTISE_IMMEDIATELY, default=False): cv.boolean,
} }
) )
), ),
@@ -179,26 +182,40 @@ async def to_code(config):
cg.add(var.set_max_interval(config[CONF_MAX_INTERVAL])) cg.add(var.set_max_interval(config[CONF_MAX_INTERVAL]))
cg.add(var.set_tx_power(config[CONF_TX_POWER])) cg.add(var.set_tx_power(config[CONF_TX_POWER]))
# Initialize FixedVectors with proper sizes
num_sensors = len(config.get(CONF_SENSORS, []))
num_binary_sensors = len(config.get(CONF_BINARY_SENSORS, []))
max_packets = max(1, num_sensors + num_binary_sensors)
# Initialize the measurements and binary_measurements FixedVectors
cg.add(cg.RawExpression(f"{var}->measurements_.init({num_sensors})"))
cg.add(cg.RawExpression(f"{var}->binary_measurements_.init({num_binary_sensors})"))
cg.add(cg.RawExpression(f"{var}->adv_packets_.init({max_packets})"))
cg.add(cg.RawExpression(f"{var}->adv_packet_sizes_.init({max_packets})"))
if CONF_ENCRYPTION_KEY in config: if CONF_ENCRYPTION_KEY in config:
key = config[CONF_ENCRYPTION_KEY] key = config[CONF_ENCRYPTION_KEY]
key_bytes = [int(key[i : i + 2], 16) for i in range(0, len(key), 2)] key_bytes = [cg.RawExpression(f"0x{key[i:i+2]}") for i in range(0, len(key), 2)]
cg.add(var.set_encryption_key(key_bytes)) key_array = cg.RawExpression(f"std::array<uint8_t, 16>{{{', '.join(str(b) for b in key_bytes)}}}")
cg.add(var.set_encryption_key(key_array))
# Add sensor measurements # Add sensor measurements
if CONF_MEASUREMENT in config: if CONF_SENSORS in config:
for measurement in config[CONF_MEASUREMENT]: for measurement in config[CONF_SENSORS]:
sensor_type = measurement[CONF_SENSOR_TYPE] sensor_type = measurement[CONF_SENSOR_TYPE]
object_id = SENSOR_DEVICE_CLASS_TO_OBJECT_ID[sensor_type] object_id = SENSOR_DEVICE_CLASS_TO_OBJECT_ID[sensor_type]
sens = await cg.get_variable(measurement[CONF_ID]) sens = await cg.get_variable(measurement[CONF_ID])
cg.add(var.add_measurement(sens, object_id)) advertise_immediately = measurement[CONF_ADVERTISE_IMMEDIATELY]
cg.add(var.add_measurement(sens, object_id, advertise_immediately))
# Add binary sensor measurements # Add binary sensor measurements
if CONF_BINARY_SENSOR in config: if CONF_BINARY_SENSORS in config:
for measurement in config[CONF_BINARY_SENSOR]: for measurement in config[CONF_BINARY_SENSORS]:
sensor_type = measurement[CONF_SENSOR_TYPE] sensor_type = measurement[CONF_SENSOR_TYPE]
object_id = BINARY_SENSOR_DEVICE_CLASS_TO_OBJECT_ID[sensor_type] object_id = BINARY_SENSOR_DEVICE_CLASS_TO_OBJECT_ID[sensor_type]
sens = await cg.get_variable(measurement[CONF_ID]) sens = await cg.get_variable(measurement[CONF_ID])
cg.add(var.add_binary_measurement(sens, object_id)) advertise_immediately = measurement[CONF_ADVERTISE_IMMEDIATELY]
cg.add(var.add_binary_measurement(sens, object_id, advertise_immediately))
cg.add_define("USE_ESP32_BLE_ADVERTISING") cg.add_define("USE_ESP32_BLE_ADVERTISING")

View File

@@ -30,6 +30,15 @@ static const uint16_t BTHOME_SERVICE_UUID = 0xFCD2;
static const uint8_t BTHOME_DEVICE_INFO_UNENCRYPTED = 0x40; // 01000000 static const uint8_t BTHOME_DEVICE_INFO_UNENCRYPTED = 0x40; // 01000000
static const uint8_t BTHOME_DEVICE_INFO_ENCRYPTED = 0x41; // 01000001 static const uint8_t BTHOME_DEVICE_INFO_ENCRYPTED = 0x41; // 01000001
// Maximum BLE advertisement payload size
// Total advertisement is 31 bytes max, minus overhead (flags + service data header)
static const size_t MAX_BLE_ADVERTISEMENT_SIZE = 31;
// Overhead: Flags (3 bytes) + Service Data length (1) + Service Data type (1) +
// Service UUID (2) + Device Info (1) = 8 bytes
// For encrypted: + Counter (4) + MIC (4) = 16 bytes total overhead
static const size_t UNENCRYPTED_OVERHEAD = 8;
static const size_t ENCRYPTED_OVERHEAD = 16;
void BTHome::dump_config() { void BTHome::dump_config() {
ESP_LOGCONFIG(TAG, "BTHome:"); ESP_LOGCONFIG(TAG, "BTHome:");
ESP_LOGCONFIG(TAG, " Min Interval: %ums", this->min_interval_); ESP_LOGCONFIG(TAG, " Min Interval: %ums", this->min_interval_);
@@ -62,132 +71,306 @@ void BTHome::setup() {
}); });
// Register callbacks for sensor state changes // Register callbacks for sensor state changes
for (auto &measurement : this->measurements_) { for (size_t i = 0; i < this->measurements_.size(); i++) {
measurement.sensor->add_on_state_callback([this](float) { this->data_changed_ = true; }); auto &measurement = this->measurements_[i];
measurement.sensor->add_on_state_callback([this, i](float) {
if (this->measurements_[i].advertise_immediately) {
this->trigger_immediate_advertising_(i, false);
} else {
this->data_changed_ = true;
}
});
} }
for (auto &measurement : this->binary_measurements_) { for (size_t i = 0; i < this->binary_measurements_.size(); i++) {
measurement.sensor->add_on_state_callback([this](bool) { this->data_changed_ = true; }); auto &measurement = this->binary_measurements_[i];
measurement.sensor->add_on_state_callback([this, i](bool) {
if (this->binary_measurements_[i].advertise_immediately) {
this->trigger_immediate_advertising_(i, true);
} else {
this->data_changed_ = true;
}
});
} }
} }
void BTHome::loop() { void BTHome::loop() {
// Handle immediate advertising requests
if (this->immediate_advertising_pending_ && this->advertising_) {
this->immediate_advertising_pending_ = false;
// Rebuild with only the immediate measurement
this->build_advertisement_packets_();
this->current_packet_index_ = 0;
this->on_advertise_();
return;
}
// Rebuild advertisement if data has changed // Rebuild advertisement if data has changed
if (this->data_changed_ && this->advertising_) { if (this->data_changed_ && this->advertising_) {
this->build_advertisement_data_(); this->build_advertisement_packets_();
this->current_packet_index_ = 0;
this->on_advertise_(); this->on_advertise_();
this->data_changed_ = false; this->data_changed_ = false;
} }
} }
void BTHome::set_encryption_key(const std::vector<uint8_t> &key) { void BTHome::set_encryption_key(const std::array<uint8_t, 16> &key) {
if (key.size() != 16) { this->encryption_enabled_ = true;
ESP_LOGE(TAG, "Encryption key must be 16 bytes"); this->encryption_key_ = key;
}
void BTHome::add_measurement(sensor::Sensor *sensor, uint8_t object_id, bool advertise_immediately) {
this->measurements_.push_back({sensor, object_id, advertise_immediately});
}
void BTHome::add_binary_measurement(binary_sensor::BinarySensor *sensor, uint8_t object_id,
bool advertise_immediately) {
this->binary_measurements_.push_back({sensor, object_id, advertise_immediately});
}
void BTHome::trigger_immediate_advertising_(uint8_t measurement_index, bool is_binary) {
this->immediate_advertising_pending_ = true;
this->immediate_adv_measurement_index_ = measurement_index;
this->immediate_adv_is_binary_ = is_binary;
}
void BTHome::build_advertisement_packets_() {
// Clear existing packets
this->adv_packets_.clear();
this->adv_packet_sizes_.clear();
const size_t overhead = this->encryption_enabled_ ? ENCRYPTED_OVERHEAD : UNENCRYPTED_OVERHEAD;
const size_t max_payload = MAX_BLE_ADVERTISEMENT_SIZE - overhead;
// Handle immediate advertising - single sensor only
if (this->immediate_advertising_pending_) {
auto packet = std::make_unique<uint8_t[]>(MAX_BLE_ADVERTISEMENT_SIZE);
uint8_t *data = packet.get();
size_t pos = 0;
// Flags
data[pos++] = 0x02;
data[pos++] = 0x01;
data[pos++] = 0x06;
// Service UUID
size_t service_data_start = pos;
pos++; // Length placeholder
data[pos++] = 0x16; // Service Data type
data[pos++] = BTHOME_SERVICE_UUID & 0xFF;
data[pos++] = (BTHOME_SERVICE_UUID >> 8) & 0xFF;
uint8_t device_info = this->encryption_enabled_ ? BTHOME_DEVICE_INFO_ENCRYPTED : BTHOME_DEVICE_INFO_UNENCRYPTED;
data[pos++] = device_info;
size_t measurement_start = pos;
// Encode the single measurement
if (this->immediate_adv_is_binary_) {
auto &measurement = this->binary_measurements_[this->immediate_adv_measurement_index_];
if (measurement.sensor->has_state()) {
pos += this->encode_binary_measurement_(data + pos, max_payload - (pos - measurement_start),
measurement.object_id, measurement.sensor->state);
}
} else {
auto &measurement = this->measurements_[this->immediate_adv_measurement_index_];
if (measurement.sensor->has_state() && !std::isnan(measurement.sensor->state)) {
pos += this->encode_measurement_(data + pos, max_payload - (pos - measurement_start), measurement.object_id,
measurement.sensor->state);
}
}
size_t measurement_len = pos - measurement_start;
if (this->encryption_enabled_ && measurement_len > 0) {
uint8_t ciphertext[MAX_BLE_ADVERTISEMENT_SIZE];
size_t ciphertext_len = 0;
if (this->encrypt_payload_(data + measurement_start, measurement_len, ciphertext, &ciphertext_len)) {
memcpy(data + measurement_start, ciphertext, ciphertext_len);
pos = measurement_start + ciphertext_len;
// Add counter
data[pos++] = this->counter_ & 0xFF;
data[pos++] = (this->counter_ >> 8) & 0xFF;
data[pos++] = (this->counter_ >> 16) & 0xFF;
data[pos++] = (this->counter_ >> 24) & 0xFF;
this->counter_++;
}
}
// Set service data length
data[service_data_start] = (pos - service_data_start - 1);
this->adv_packets_.push_back(std::move(packet));
this->adv_packet_sizes_.push_back(pos);
return; 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) { // Normal cycling: Build packets that fit within max size
this->measurements_.push_back({sensor, object_id}); auto packet = std::make_unique<uint8_t[]>(MAX_BLE_ADVERTISEMENT_SIZE);
} uint8_t *data = packet.get();
size_t pos = 0;
size_t measurement_start = 0;
bool packet_started = false;
void BTHome::add_binary_measurement(binary_sensor::BinarySensor *sensor, uint8_t object_id) { auto start_new_packet = [&]() {
this->binary_measurements_.push_back({sensor, object_id}); pos = 0;
} // Flags
data[pos++] = 0x02;
data[pos++] = 0x01;
data[pos++] = 0x06;
void BTHome::build_advertisement_data_() { // Service UUID
std::vector<uint8_t> service_data; measurement_start = pos;
pos++; // Length placeholder
data[pos++] = 0x16; // Service Data type
data[pos++] = BTHOME_SERVICE_UUID & 0xFF;
data[pos++] = (BTHOME_SERVICE_UUID >> 8) & 0xFF;
// 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; uint8_t device_info = this->encryption_enabled_ ? BTHOME_DEVICE_INFO_ENCRYPTED : BTHOME_DEVICE_INFO_UNENCRYPTED;
service_data.push_back(device_info); data[pos++] = device_info;
measurement_start = pos;
packet_started = true;
};
// Build measurement data auto finish_packet = [&]() {
std::vector<uint8_t> measurement_data; if (!packet_started)
return;
size_t measurement_len = pos - measurement_start;
if (measurement_len == 0) {
packet_started = false;
return;
}
if (this->encryption_enabled_) {
uint8_t ciphertext[MAX_BLE_ADVERTISEMENT_SIZE];
size_t ciphertext_len = 0;
if (this->encrypt_payload_(data + measurement_start, measurement_len, ciphertext, &ciphertext_len)) {
memcpy(data + measurement_start, ciphertext, ciphertext_len);
pos = measurement_start + ciphertext_len;
// Add counter
data[pos++] = this->counter_ & 0xFF;
data[pos++] = (this->counter_ >> 8) & 0xFF;
data[pos++] = (this->counter_ >> 16) & 0xFF;
data[pos++] = (this->counter_ >> 24) & 0xFF;
this->counter_++;
}
}
// Set service data length
data[measurement_start - 5] = (pos - measurement_start + 4);
this->adv_packets_.push_back(std::move(packet));
this->adv_packet_sizes_.push_back(pos);
packet = std::make_unique<uint8_t[]>(MAX_BLE_ADVERTISEMENT_SIZE);
data = packet.get();
packet_started = false;
};
start_new_packet();
// Add all sensor measurements // Add all sensor measurements
for (const auto &measurement : this->measurements_) { for (const auto &measurement : this->measurements_) {
if (measurement.sensor->has_state() && !std::isnan(measurement.sensor->state)) { if (!measurement.sensor->has_state() || std::isnan(measurement.sensor->state))
this->encode_measurement_(measurement_data, measurement.object_id, measurement.sensor->state); continue;
size_t encoded_size = this->encode_measurement_(nullptr, 0, measurement.object_id, measurement.sensor->state);
// Check if adding this measurement would exceed packet size
if (pos - measurement_start + encoded_size > max_payload) {
finish_packet();
start_new_packet();
} }
pos += this->encode_measurement_(data + pos, max_payload - (pos - measurement_start), measurement.object_id,
measurement.sensor->state);
} }
// Add all binary sensor measurements // Add all binary sensor measurements
for (const auto &measurement : this->binary_measurements_) { for (const auto &measurement : this->binary_measurements_) {
if (measurement.sensor->has_state()) { if (!measurement.sensor->has_state())
this->encode_binary_measurement_(measurement_data, measurement.object_id, measurement.sensor->state); continue;
size_t encoded_size = 2; // Binary sensors are always 2 bytes (object_id + value)
// Check if adding this measurement would exceed packet size
if (pos - measurement_start + encoded_size > max_payload) {
finish_packet();
start_new_packet();
}
pos += this->encode_binary_measurement_(data + pos, max_payload - (pos - measurement_start),
measurement.object_id, measurement.sensor->state);
}
finish_packet();
ESP_LOGD(TAG, "Built %d advertisement packet(s)", this->adv_packets_.size());
}
size_t BTHome::encode_measurement_(uint8_t *data, size_t max_len, uint8_t object_id, float value) {
// If data is nullptr, just calculate the size
if (data == nullptr) {
switch (object_id) {
case 0x01: // battery (uint8)
return 2;
case 0x02: // temperature (sint16)
case 0x03: // humidity (uint16)
case 0x06: // mass (uint16)
case 0x08: // dewpoint (sint16)
case 0x0C: // voltage (uint16)
case 0x0D: // PM2.5 (uint16)
case 0x0E: // PM10 (uint16)
case 0x12: // CO2 (uint16)
case 0x13: // TVOC (uint16)
case 0x14: // moisture (uint16)
case 0x43: // current (uint16)
case 0x44: // speed (uint16)
return 3;
case 0x04: // pressure (uint24)
case 0x05: // illuminance (uint24)
case 0x0A: // energy (uint24)
case 0x0B: // power (uint24)
return 4;
case 0x50: // timestamp (uint32)
return 5;
default:
return 0;
} }
} }
if (this->encryption_enabled_) { size_t pos = 0;
// Encrypt the measurement data data[pos++] = object_id;
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 // Encode based on object ID
switch (object_id) { switch (object_id) {
case 0x01: // battery (uint8, 1%) case 0x01: // battery (uint8, 1%)
{ {
uint8_t encoded = static_cast<uint8_t>(std::round(value)); if (max_len < 2)
data.push_back(encoded); return 0;
data[pos++] = static_cast<uint8_t>(std::round(value));
break; break;
} }
case 0x02: // temperature (sint16, 0.01°C) case 0x02: // temperature (sint16, 0.01°C)
case 0x08: // dewpoint (sint16, 0.01°C) case 0x08: // dewpoint (sint16, 0.01°C)
{ {
if (max_len < 3)
return 0;
int16_t encoded = static_cast<int16_t>(std::round(value * 100.0f)); int16_t encoded = static_cast<int16_t>(std::round(value * 100.0f));
data.push_back(encoded & 0xFF); data[pos++] = encoded & 0xFF;
data.push_back((encoded >> 8) & 0xFF); data[pos++] = (encoded >> 8) & 0xFF;
break; break;
} }
case 0x03: // humidity (uint16, 0.01%) case 0x03: // humidity (uint16, 0.01%)
case 0x14: // moisture (uint16, 0.01%) case 0x14: // moisture (uint16, 0.01%)
{ {
if (max_len < 3)
return 0;
uint16_t encoded = static_cast<uint16_t>(std::round(value * 100.0f)); uint16_t encoded = static_cast<uint16_t>(std::round(value * 100.0f));
data.push_back(encoded & 0xFF); data[pos++] = encoded & 0xFF;
data.push_back((encoded >> 8) & 0xFF); data[pos++] = (encoded >> 8) & 0xFF;
break; break;
} }
case 0x04: // pressure (uint24, 0.01 hPa) case 0x04: // pressure (uint24, 0.01 hPa)
@@ -195,28 +378,34 @@ void BTHome::encode_measurement_(std::vector<uint8_t> &data, uint8_t object_id,
case 0x0A: // energy (uint24, 0.001 kWh) case 0x0A: // energy (uint24, 0.001 kWh)
case 0x0B: // power (uint24, 0.01 W) case 0x0B: // power (uint24, 0.01 W)
{ {
if (max_len < 4)
return 0;
float factor = (object_id == 0x0A) ? 1000.0f : 100.0f; float factor = (object_id == 0x0A) ? 1000.0f : 100.0f;
uint32_t encoded = static_cast<uint32_t>(std::round(value * factor)); uint32_t encoded = static_cast<uint32_t>(std::round(value * factor));
data.push_back(encoded & 0xFF); data[pos++] = encoded & 0xFF;
data.push_back((encoded >> 8) & 0xFF); data[pos++] = (encoded >> 8) & 0xFF;
data.push_back((encoded >> 16) & 0xFF); data[pos++] = (encoded >> 16) & 0xFF;
break; break;
} }
case 0x06: // mass (uint16, 0.01 kg) case 0x06: // mass (uint16, 0.01 kg)
case 0x43: // current (uint16, 0.001 A) case 0x43: // current (uint16, 0.001 A)
case 0x44: // speed (uint16, 0.01 m/s) case 0x44: // speed (uint16, 0.01 m/s)
{ {
if (max_len < 3)
return 0;
float factor = (object_id == 0x43) ? 1000.0f : 100.0f; float factor = (object_id == 0x43) ? 1000.0f : 100.0f;
uint16_t encoded = static_cast<uint16_t>(std::round(value * factor)); uint16_t encoded = static_cast<uint16_t>(std::round(value * factor));
data.push_back(encoded & 0xFF); data[pos++] = encoded & 0xFF;
data.push_back((encoded >> 8) & 0xFF); data[pos++] = (encoded >> 8) & 0xFF;
break; break;
} }
case 0x0C: // voltage (uint16, 0.001 V) case 0x0C: // voltage (uint16, 0.001 V)
{ {
if (max_len < 3)
return 0;
uint16_t encoded = static_cast<uint16_t>(std::round(value * 1000.0f)); uint16_t encoded = static_cast<uint16_t>(std::round(value * 1000.0f));
data.push_back(encoded & 0xFF); data[pos++] = encoded & 0xFF;
data.push_back((encoded >> 8) & 0xFF); data[pos++] = (encoded >> 8) & 0xFF;
break; break;
} }
case 0x0D: // PM2.5 (uint16, 1 µg/m³) case 0x0D: // PM2.5 (uint16, 1 µg/m³)
@@ -224,34 +413,46 @@ void BTHome::encode_measurement_(std::vector<uint8_t> &data, uint8_t object_id,
case 0x12: // CO2 (uint16, 1 ppm) case 0x12: // CO2 (uint16, 1 ppm)
case 0x13: // TVOC (uint16, 1 µg/m³) case 0x13: // TVOC (uint16, 1 µg/m³)
{ {
if (max_len < 3)
return 0;
uint16_t encoded = static_cast<uint16_t>(std::round(value)); uint16_t encoded = static_cast<uint16_t>(std::round(value));
data.push_back(encoded & 0xFF); data[pos++] = encoded & 0xFF;
data.push_back((encoded >> 8) & 0xFF); data[pos++] = (encoded >> 8) & 0xFF;
break; break;
} }
case 0x50: // timestamp (uint32, seconds) case 0x50: // timestamp (uint32, seconds)
{ {
if (max_len < 5)
return 0;
uint32_t encoded = static_cast<uint32_t>(value); uint32_t encoded = static_cast<uint32_t>(value);
data.push_back(encoded & 0xFF); data[pos++] = encoded & 0xFF;
data.push_back((encoded >> 8) & 0xFF); data[pos++] = (encoded >> 8) & 0xFF;
data.push_back((encoded >> 16) & 0xFF); data[pos++] = (encoded >> 16) & 0xFF;
data.push_back((encoded >> 24) & 0xFF); data[pos++] = (encoded >> 24) & 0xFF;
break; break;
} }
default: default:
ESP_LOGW(TAG, "Unsupported sensor object ID: 0x%02X", object_id); ESP_LOGW(TAG, "Unsupported sensor object ID: 0x%02X", object_id);
// Remove the object ID we just added return 0;
data.pop_back();
break;
}
} }
void BTHome::encode_binary_measurement_(std::vector<uint8_t> &data, uint8_t object_id, bool value) { return pos;
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) { size_t BTHome::encode_binary_measurement_(uint8_t *data, size_t max_len, uint8_t object_id, bool value) {
if (data == nullptr)
return 2; // Binary sensors are always 2 bytes
if (max_len < 2)
return 0;
data[0] = object_id;
data[1] = value ? 0x01 : 0x00;
return 2;
}
bool BTHome::encrypt_payload_(const uint8_t *plaintext, size_t plaintext_len, uint8_t *ciphertext,
size_t *ciphertext_len) {
if (!this->encryption_enabled_) { if (!this->encryption_enabled_) {
return false; return false;
} }
@@ -261,20 +462,17 @@ bool BTHome::encrypt_payload_(const std::vector<uint8_t> &plaintext, std::vector
esp_read_mac(mac, ESP_MAC_BT); esp_read_mac(mac, ESP_MAC_BT);
// Build nonce according to BTHome spec: // Build nonce according to BTHome spec:
// MAC (6 bytes) + UUID reversed (2 bytes) + device info (1 byte) + counter (4 bytes) = 13 bytes // MAC (6 bytes) + UUID (2 bytes) + device info (1 byte) + counter (4 bytes) = 13 bytes
uint8_t nonce[13]; uint8_t nonce[13];
memcpy(nonce, mac, 6); memcpy(nonce, mac, 6);
nonce[6] = BTHOME_SERVICE_UUID & 0xFF; // UUID byte 1 nonce[6] = BTHOME_SERVICE_UUID & 0xFF; // UUID byte 1
nonce[7] = (BTHOME_SERVICE_UUID >> 8) & 0xFF; // UUID byte 2 (already little-endian) nonce[7] = (BTHOME_SERVICE_UUID >> 8) & 0xFF; // UUID byte 2
nonce[8] = BTHOME_DEVICE_INFO_ENCRYPTED; // Device info byte nonce[8] = BTHOME_DEVICE_INFO_ENCRYPTED; // Device info byte
nonce[9] = this->counter_ & 0xFF; // Counter byte 0 nonce[9] = this->counter_ & 0xFF; // Counter byte 0
nonce[10] = (this->counter_ >> 8) & 0xFF; // Counter byte 1 nonce[10] = (this->counter_ >> 8) & 0xFF; // Counter byte 1
nonce[11] = (this->counter_ >> 16) & 0xFF; // Counter byte 2 nonce[11] = (this->counter_ >> 16) & 0xFF; // Counter byte 2
nonce[12] = (this->counter_ >> 24) & 0xFF; // Counter byte 3 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 // Initialize mbedtls CCM context
mbedtls_ccm_context ctx; mbedtls_ccm_context ctx;
mbedtls_ccm_init(&ctx); mbedtls_ccm_init(&ctx);
@@ -287,10 +485,9 @@ bool BTHome::encrypt_payload_(const std::vector<uint8_t> &plaintext, std::vector
return false; return false;
} }
// Encrypt and generate tag // Encrypt and generate tag (4-byte MIC)
// BTHome uses no additional authenticated data (AAD) ret = mbedtls_ccm_encrypt_and_tag(&ctx, plaintext_len, nonce, sizeof(nonce), nullptr, 0, plaintext, ciphertext,
ret = mbedtls_ccm_encrypt_and_tag(&ctx, plaintext.size(), nonce, sizeof(nonce), nullptr, 0, plaintext.data(), ciphertext + plaintext_len, 4);
ciphertext.data(), ciphertext.data() + plaintext.size(), 4);
mbedtls_ccm_free(&ctx); mbedtls_ccm_free(&ctx);
@@ -299,28 +496,42 @@ bool BTHome::encrypt_payload_(const std::vector<uint8_t> &plaintext, std::vector
return false; return false;
} }
*ciphertext_len = plaintext_len + 4; // Ciphertext + 4-byte MIC
return true; return true;
} }
void BTHome::on_advertise_() { void BTHome::on_advertise_() {
// Build advertisement data if needed // Build advertisement packets if needed
if (this->data_changed_ || this->adv_data_.empty()) { if (this->data_changed_ || this->adv_packets_.empty()) {
this->build_advertisement_data_(); this->build_advertisement_packets_();
this->data_changed_ = false; this->data_changed_ = false;
} }
if (this->adv_packets_.empty()) {
ESP_LOGW(TAG, "No advertisement packets to send");
return;
}
// Send current packet
uint8_t *packet = this->adv_packets_[this->current_packet_index_].get();
uint16_t size = this->adv_packet_sizes_[this->current_packet_index_];
ESP_LOGD(TAG, "Setting BLE TX power"); ESP_LOGD(TAG, "Setting BLE TX power");
esp_err_t err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_); esp_err_t err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_ble_tx_power_set failed: %s", esp_err_to_name(err)); 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()); ESP_LOGD(TAG, "Starting BTHome advertisement packet %d/%d (%d bytes)", this->current_packet_index_ + 1,
err = esp_ble_gap_config_adv_data_raw(this->adv_data_.data(), this->adv_data_.size()); this->adv_packets_.size(), size);
err = esp_ble_gap_config_adv_data_raw(packet, size);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gap_config_adv_data_raw failed: %s", esp_err_to_name(err)); ESP_LOGE(TAG, "esp_ble_gap_config_adv_data_raw failed: %s", esp_err_to_name(err));
return; return;
} }
// Cycle to next packet for next time
this->current_packet_index_ = (this->current_packet_index_ + 1) % this->adv_packets_.size();
} }
void BTHome::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { void BTHome::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {

View File

@@ -4,6 +4,7 @@
#include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble.h"
#include "esphome/components/sensor/sensor.h" #include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_ESP32 #ifdef USE_ESP32
@@ -13,7 +14,6 @@
#include <esp_gap_ble_api.h> #include <esp_gap_ble_api.h>
#include <array> #include <array>
#include <vector>
namespace esphome { namespace esphome {
namespace bthome { namespace bthome {
@@ -23,11 +23,13 @@ using namespace esp32_ble;
struct SensorMeasurement { struct SensorMeasurement {
sensor::Sensor *sensor; sensor::Sensor *sensor;
uint8_t object_id; uint8_t object_id;
bool advertise_immediately;
}; };
struct BinarySensorMeasurement { struct BinarySensorMeasurement {
binary_sensor::BinarySensor *sensor; binary_sensor::BinarySensor *sensor;
uint8_t object_id; uint8_t object_id;
bool advertise_immediately;
}; };
class BTHome : public Component, public GAPEventHandler, public Parented<ESP32BLE> { class BTHome : public Component, public GAPEventHandler, public Parented<ESP32BLE> {
@@ -41,21 +43,22 @@ class BTHome : public Component, public GAPEventHandler, public Parented<ESP32BL
void set_max_interval(uint16_t val) { this->max_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_tx_power(esp_power_level_t val) { this->tx_power_ = val; }
void set_encryption_key(const std::vector<uint8_t> &key); void set_encryption_key(const std::array<uint8_t, 16> &key);
void add_measurement(sensor::Sensor *sensor, uint8_t object_id); void add_measurement(sensor::Sensor *sensor, uint8_t object_id, bool advertise_immediately);
void add_binary_measurement(binary_sensor::BinarySensor *sensor, uint8_t object_id); void add_binary_measurement(binary_sensor::BinarySensor *sensor, uint8_t object_id, bool advertise_immediately);
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
protected: protected:
void on_advertise_(); void on_advertise_();
void build_advertisement_data_(); void build_advertisement_packets_();
void encode_measurement_(std::vector<uint8_t> &data, uint8_t object_id, float value); size_t encode_measurement_(uint8_t *data, size_t max_len, uint8_t object_id, float value);
void encode_binary_measurement_(std::vector<uint8_t> &data, uint8_t object_id, bool value); size_t encode_binary_measurement_(uint8_t *data, size_t max_len, uint8_t object_id, bool value);
bool encrypt_payload_(const std::vector<uint8_t> &plaintext, std::vector<uint8_t> &ciphertext); bool encrypt_payload_(const uint8_t *plaintext, size_t plaintext_len, uint8_t *ciphertext, size_t *ciphertext_len);
void trigger_immediate_advertising_(uint8_t measurement_index, bool is_binary);
std::vector<SensorMeasurement> measurements_; FixedVector<SensorMeasurement> measurements_;
std::vector<BinarySensorMeasurement> binary_measurements_; FixedVector<BinarySensorMeasurement> binary_measurements_;
uint16_t min_interval_{}; uint16_t min_interval_{};
uint16_t max_interval_{}; uint16_t max_interval_{};
@@ -68,9 +71,14 @@ class BTHome : public Component, public GAPEventHandler, public Parented<ESP32BL
std::array<uint8_t, 16> encryption_key_{}; std::array<uint8_t, 16> encryption_key_{};
uint32_t counter_{0}; uint32_t counter_{0};
// Cached advertisement data // Advertisement cycling support
std::vector<uint8_t> adv_data_; FixedVector<std::unique_ptr<uint8_t[]>> adv_packets_; // Multiple advertisement packets
FixedVector<uint16_t> adv_packet_sizes_; // Size of each packet
uint8_t current_packet_index_{0};
bool data_changed_{true}; bool data_changed_{true};
bool immediate_advertising_pending_{false};
uint8_t immediate_adv_measurement_index_{0};
bool immediate_adv_is_binary_{false};
}; };
} // namespace bthome } // namespace bthome

View File

@@ -18,15 +18,16 @@ binary_sensor:
name: "Test Door" name: "Test Door"
bthome: bthome:
measurement: sensors:
- type: temperature - type: temperature
id: test_temperature id: test_temperature
- type: humidity - type: humidity
id: test_humidity id: test_humidity
- type: battery - type: battery
id: test_battery id: test_battery
binary_sensor: binary_sensors:
- type: motion - type: motion
id: test_motion id: test_motion
advertise_immediately: true
- type: door - type: door
id: test_door id: test_door

View File

@@ -10,9 +10,10 @@ binary_sensor:
bthome: bthome:
encryption_key: "231d39c1d7cc1ab1aee224cd096db932" encryption_key: "231d39c1d7cc1ab1aee224cd096db932"
measurement: sensors:
- type: temperature - type: temperature
id: test_temperature id: test_temperature
binary_sensor: advertise_immediately: true
binary_sensors:
- type: motion - type: motion
id: test_motion id: test_motion