From f81a3a8c643d08bfd5c85387c83d9fe9af89e991 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 22:37:55 +0000 Subject: [PATCH] [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 ``` --- esphome/components/bthome/__init__.py | 41 +- esphome/components/bthome/bthome.cpp | 427 +++++++++++++----- esphome/components/bthome/bthome.h | 32 +- tests/components/bthome/common.yaml | 5 +- .../bthome/test_encryption.esp32-idf.yaml | 5 +- 5 files changed, 374 insertions(+), 136 deletions(-) diff --git a/esphome/components/bthome/__init__.py b/esphome/components/bthome/__init__.py index fcba825f9b..7cadbe26fc 100644 --- a/esphome/components/bthome/__init__.py +++ b/esphome/components/bthome/__init__.py @@ -4,8 +4,9 @@ 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_BINARY_SENSOR, + CONF_BINARY_SENSORS, CONF_ID, + CONF_SENSORS, CONF_TX_POWER, DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY_CHARGING, @@ -52,11 +53,11 @@ BTHome = bthome_ns.class_( ) # Configuration constants -CONF_MEASUREMENT = "measurement" CONF_ENCRYPTION_KEY = "encryption_key" CONF_MIN_INTERVAL = "min_interval" CONF_MAX_INTERVAL = "max_interval" CONF_SENSOR_TYPE = "type" +CONF_ADVERTISE_IMMEDIATELY = "advertise_immediately" # BTHome object IDs for sensors (mapping from device class to BTHome 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.Optional(CONF_ENCRYPTION_KEY): validate_encryption_key, - cv.Optional(CONF_MEASUREMENT): cv.ensure_list( + cv.Optional(CONF_SENSORS): cv.ensure_list( cv.Schema( { cv.Required(CONF_SENSOR_TYPE): cv.one_of( *SENSOR_DEVICE_CLASS_TO_OBJECT_ID.keys(), lower=True ), 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.Required(CONF_SENSOR_TYPE): cv.one_of( *BINARY_SENSOR_DEVICE_CLASS_TO_OBJECT_ID.keys(), lower=True ), 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_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: 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)) + key_bytes = [cg.RawExpression(f"0x{key[i:i+2]}") for i in range(0, len(key), 2)] + key_array = cg.RawExpression(f"std::array{{{', '.join(str(b) for b in key_bytes)}}}") + cg.add(var.set_encryption_key(key_array)) # Add sensor measurements - if CONF_MEASUREMENT in config: - for measurement in config[CONF_MEASUREMENT]: + if CONF_SENSORS in config: + for measurement in config[CONF_SENSORS]: sensor_type = measurement[CONF_SENSOR_TYPE] object_id = SENSOR_DEVICE_CLASS_TO_OBJECT_ID[sensor_type] 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 - if CONF_BINARY_SENSOR in config: - for measurement in config[CONF_BINARY_SENSOR]: + if CONF_BINARY_SENSORS in config: + for measurement in config[CONF_BINARY_SENSORS]: sensor_type = measurement[CONF_SENSOR_TYPE] object_id = BINARY_SENSOR_DEVICE_CLASS_TO_OBJECT_ID[sensor_type] 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") diff --git a/esphome/components/bthome/bthome.cpp b/esphome/components/bthome/bthome.cpp index eea5e9801f..c34cafcfe6 100644 --- a/esphome/components/bthome/bthome.cpp +++ b/esphome/components/bthome/bthome.cpp @@ -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_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() { ESP_LOGCONFIG(TAG, "BTHome:"); ESP_LOGCONFIG(TAG, " Min Interval: %ums", this->min_interval_); @@ -62,132 +71,306 @@ void BTHome::setup() { }); // Register callbacks for sensor state changes - for (auto &measurement : this->measurements_) { - measurement.sensor->add_on_state_callback([this](float) { this->data_changed_ = true; }); + for (size_t i = 0; i < this->measurements_.size(); i++) { + 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_) { - measurement.sensor->add_on_state_callback([this](bool) { this->data_changed_ = true; }); + for (size_t i = 0; i < this->binary_measurements_.size(); i++) { + 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() { + // 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 if (this->data_changed_ && this->advertising_) { - this->build_advertisement_data_(); + this->build_advertisement_packets_(); + this->current_packet_index_ = 0; this->on_advertise_(); this->data_changed_ = false; } } -void BTHome::set_encryption_key(const std::vector &key) { - if (key.size() != 16) { - ESP_LOGE(TAG, "Encryption key must be 16 bytes"); +void BTHome::set_encryption_key(const std::array &key) { + this->encryption_enabled_ = true; + 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(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; } - 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}); -} + // Normal cycling: Build packets that fit within max size + auto packet = std::make_unique(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) { - this->binary_measurements_.push_back({sensor, object_id}); -} + auto start_new_packet = [&]() { + pos = 0; + // Flags + data[pos++] = 0x02; + data[pos++] = 0x01; + data[pos++] = 0x06; -void BTHome::build_advertisement_data_() { - std::vector service_data; + // Service UUID + 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); + uint8_t device_info = this->encryption_enabled_ ? BTHOME_DEVICE_INFO_ENCRYPTED : BTHOME_DEVICE_INFO_UNENCRYPTED; + data[pos++] = device_info; + measurement_start = pos; + packet_started = true; + }; - // 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); + auto finish_packet = [&]() { + if (!packet_started) + return; - // Build measurement data - std::vector measurement_data; + 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(MAX_BLE_ADVERTISEMENT_SIZE); + data = packet.get(); + packet_started = false; + }; + + start_new_packet(); // 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); + if (!measurement.sensor->has_state() || std::isnan(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 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 (!measurement.sensor->has_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); } - if (this->encryption_enabled_) { - // Encrypt the measurement data - std::vector ciphertext; - if (this->encrypt_payload_(measurement_data, ciphertext)) { - // Add ciphertext to service data - service_data.insert(service_data.end(), ciphertext.begin(), ciphertext.end()); + finish_packet(); - // 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()); + ESP_LOGD(TAG, "Built %d advertisement packet(s)", this->adv_packets_.size()); } -void BTHome::encode_measurement_(std::vector &data, uint8_t object_id, float value) { - data.push_back(object_id); +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; + } + } + + size_t pos = 0; + data[pos++] = object_id; // Encode based on object ID switch (object_id) { case 0x01: // battery (uint8, 1%) { - uint8_t encoded = static_cast(std::round(value)); - data.push_back(encoded); + if (max_len < 2) + return 0; + data[pos++] = static_cast(std::round(value)); break; } case 0x02: // temperature (sint16, 0.01°C) case 0x08: // dewpoint (sint16, 0.01°C) { + if (max_len < 3) + return 0; int16_t encoded = static_cast(std::round(value * 100.0f)); - data.push_back(encoded & 0xFF); - data.push_back((encoded >> 8) & 0xFF); + data[pos++] = encoded & 0xFF; + data[pos++] = (encoded >> 8) & 0xFF; break; } case 0x03: // humidity (uint16, 0.01%) case 0x14: // moisture (uint16, 0.01%) { + if (max_len < 3) + return 0; uint16_t encoded = static_cast(std::round(value * 100.0f)); - data.push_back(encoded & 0xFF); - data.push_back((encoded >> 8) & 0xFF); + data[pos++] = encoded & 0xFF; + data[pos++] = (encoded >> 8) & 0xFF; break; } case 0x04: // pressure (uint24, 0.01 hPa) @@ -195,28 +378,34 @@ void BTHome::encode_measurement_(std::vector &data, uint8_t object_id, case 0x0A: // energy (uint24, 0.001 kWh) case 0x0B: // power (uint24, 0.01 W) { + if (max_len < 4) + return 0; float factor = (object_id == 0x0A) ? 1000.0f : 100.0f; uint32_t encoded = static_cast(std::round(value * factor)); - data.push_back(encoded & 0xFF); - data.push_back((encoded >> 8) & 0xFF); - data.push_back((encoded >> 16) & 0xFF); + data[pos++] = encoded & 0xFF; + data[pos++] = (encoded >> 8) & 0xFF; + data[pos++] = (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) { + if (max_len < 3) + return 0; float factor = (object_id == 0x43) ? 1000.0f : 100.0f; uint16_t encoded = static_cast(std::round(value * factor)); - data.push_back(encoded & 0xFF); - data.push_back((encoded >> 8) & 0xFF); + data[pos++] = encoded & 0xFF; + data[pos++] = (encoded >> 8) & 0xFF; break; } case 0x0C: // voltage (uint16, 0.001 V) { + if (max_len < 3) + return 0; uint16_t encoded = static_cast(std::round(value * 1000.0f)); - data.push_back(encoded & 0xFF); - data.push_back((encoded >> 8) & 0xFF); + data[pos++] = encoded & 0xFF; + data[pos++] = (encoded >> 8) & 0xFF; break; } case 0x0D: // PM2.5 (uint16, 1 µg/m³) @@ -224,34 +413,46 @@ void BTHome::encode_measurement_(std::vector &data, uint8_t object_id, case 0x12: // CO2 (uint16, 1 ppm) case 0x13: // TVOC (uint16, 1 µg/m³) { + if (max_len < 3) + return 0; uint16_t encoded = static_cast(std::round(value)); - data.push_back(encoded & 0xFF); - data.push_back((encoded >> 8) & 0xFF); + data[pos++] = encoded & 0xFF; + data[pos++] = (encoded >> 8) & 0xFF; break; } case 0x50: // timestamp (uint32, seconds) { + if (max_len < 5) + return 0; uint32_t encoded = static_cast(value); - data.push_back(encoded & 0xFF); - data.push_back((encoded >> 8) & 0xFF); - data.push_back((encoded >> 16) & 0xFF); - data.push_back((encoded >> 24) & 0xFF); + data[pos++] = encoded & 0xFF; + data[pos++] = (encoded >> 8) & 0xFF; + data[pos++] = (encoded >> 16) & 0xFF; + data[pos++] = (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; + return 0; } + + return pos; } -void BTHome::encode_binary_measurement_(std::vector &data, uint8_t object_id, bool value) { - data.push_back(object_id); - data.push_back(value ? 0x01 : 0x00); +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 std::vector &plaintext, std::vector &ciphertext) { +bool BTHome::encrypt_payload_(const uint8_t *plaintext, size_t plaintext_len, uint8_t *ciphertext, + size_t *ciphertext_len) { if (!this->encryption_enabled_) { return false; } @@ -261,20 +462,17 @@ bool BTHome::encrypt_payload_(const std::vector &plaintext, std::vector 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 + // MAC (6 bytes) + UUID (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[7] = (BTHOME_SERVICE_UUID >> 8) & 0xFF; // UUID byte 2 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); @@ -287,10 +485,9 @@ bool BTHome::encrypt_payload_(const std::vector &plaintext, std::vector 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); + // Encrypt and generate tag (4-byte MIC) + ret = mbedtls_ccm_encrypt_and_tag(&ctx, plaintext_len, nonce, sizeof(nonce), nullptr, 0, plaintext, ciphertext, + ciphertext + plaintext_len, 4); mbedtls_ccm_free(&ctx); @@ -299,28 +496,42 @@ bool BTHome::encrypt_payload_(const std::vector &plaintext, std::vector return false; } + *ciphertext_len = plaintext_len + 4; // Ciphertext + 4-byte MIC return true; } void BTHome::on_advertise_() { - // Build advertisement data if needed - if (this->data_changed_ || this->adv_data_.empty()) { - this->build_advertisement_data_(); + // Build advertisement packets if needed + if (this->data_changed_ || this->adv_packets_.empty()) { + this->build_advertisement_packets_(); 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_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()); + ESP_LOGD(TAG, "Starting BTHome advertisement packet %d/%d (%d bytes)", this->current_packet_index_ + 1, + this->adv_packets_.size(), size); + err = esp_ble_gap_config_adv_data_raw(packet, size); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gap_config_adv_data_raw failed: %s", esp_err_to_name(err)); 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) { diff --git a/esphome/components/bthome/bthome.h b/esphome/components/bthome/bthome.h index c841b79ee7..8cabc522d1 100644 --- a/esphome/components/bthome/bthome.h +++ b/esphome/components/bthome/bthome.h @@ -4,6 +4,7 @@ #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #ifdef USE_ESP32 @@ -13,7 +14,6 @@ #include #include -#include namespace esphome { namespace bthome { @@ -23,11 +23,13 @@ using namespace esp32_ble; struct SensorMeasurement { sensor::Sensor *sensor; uint8_t object_id; + bool advertise_immediately; }; struct BinarySensorMeasurement { binary_sensor::BinarySensor *sensor; uint8_t object_id; + bool advertise_immediately; }; class BTHome : public Component, public GAPEventHandler, public Parented { @@ -41,21 +43,22 @@ class BTHome : public Component, public GAPEventHandler, public Parentedmax_interval_ = val; } void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; } - void set_encryption_key(const std::vector &key); - void add_measurement(sensor::Sensor *sensor, uint8_t object_id); - void add_binary_measurement(binary_sensor::BinarySensor *sensor, uint8_t object_id); + void set_encryption_key(const std::array &key); + 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, bool advertise_immediately); 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 &data, uint8_t object_id, float value); - void encode_binary_measurement_(std::vector &data, uint8_t object_id, bool value); - bool encrypt_payload_(const std::vector &plaintext, std::vector &ciphertext); + void build_advertisement_packets_(); + size_t encode_measurement_(uint8_t *data, size_t max_len, uint8_t object_id, float value); + size_t encode_binary_measurement_(uint8_t *data, size_t max_len, uint8_t object_id, bool value); + 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 measurements_; - std::vector binary_measurements_; + FixedVector measurements_; + FixedVector binary_measurements_; uint16_t min_interval_{}; uint16_t max_interval_{}; @@ -68,9 +71,14 @@ class BTHome : public Component, public GAPEventHandler, public Parented encryption_key_{}; uint32_t counter_{0}; - // Cached advertisement data - std::vector adv_data_; + // Advertisement cycling support + FixedVector> adv_packets_; // Multiple advertisement packets + FixedVector adv_packet_sizes_; // Size of each packet + uint8_t current_packet_index_{0}; bool data_changed_{true}; + bool immediate_advertising_pending_{false}; + uint8_t immediate_adv_measurement_index_{0}; + bool immediate_adv_is_binary_{false}; }; } // namespace bthome diff --git a/tests/components/bthome/common.yaml b/tests/components/bthome/common.yaml index 3be746e26a..b7ab8985a5 100644 --- a/tests/components/bthome/common.yaml +++ b/tests/components/bthome/common.yaml @@ -18,15 +18,16 @@ binary_sensor: name: "Test Door" bthome: - measurement: + sensors: - type: temperature id: test_temperature - type: humidity id: test_humidity - type: battery id: test_battery - binary_sensor: + binary_sensors: - type: motion id: test_motion + advertise_immediately: true - type: door id: test_door diff --git a/tests/components/bthome/test_encryption.esp32-idf.yaml b/tests/components/bthome/test_encryption.esp32-idf.yaml index dd1c654e83..36a5b324f4 100644 --- a/tests/components/bthome/test_encryption.esp32-idf.yaml +++ b/tests/components/bthome/test_encryption.esp32-idf.yaml @@ -10,9 +10,10 @@ binary_sensor: bthome: encryption_key: "231d39c1d7cc1ab1aee224cd096db932" - measurement: + sensors: - type: temperature id: test_temperature - binary_sensor: + advertise_immediately: true + binary_sensors: - type: motion id: test_motion