mirror of
				https://github.com/esphome/esphome.git
				synced 2025-11-04 00:51:49 +00:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			2025.8.0b3
			...
			jesserockz
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					bc3212d981 | 
							
								
								
									
										2
									
								
								Doxyfile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Doxyfile
									
									
									
									
									
								
							@@ -48,7 +48,7 @@ PROJECT_NAME           = ESPHome
 | 
			
		||||
# could be handy for archiving the generated documentation or if some version
 | 
			
		||||
# control system is used.
 | 
			
		||||
 | 
			
		||||
PROJECT_NUMBER         = 2025.8.0b3
 | 
			
		||||
PROJECT_NUMBER         = 2025.8.0-dev
 | 
			
		||||
 | 
			
		||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
 | 
			
		||||
# for a project that appears at the top of each page and should give viewer a
 | 
			
		||||
 
 | 
			
		||||
@@ -476,7 +476,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
 | 
			
		||||
        from esphome.components.api.client import run_logs
 | 
			
		||||
 | 
			
		||||
        return run_logs(config, addresses_to_use)
 | 
			
		||||
    if get_port_type(port) in ("NETWORK", "MQTT") and "mqtt" in config:
 | 
			
		||||
    if get_port_type(port) == "MQTT" and "mqtt" in config:
 | 
			
		||||
        from esphome import mqtt
 | 
			
		||||
 | 
			
		||||
        return mqtt.show_logs(
 | 
			
		||||
 
 | 
			
		||||
@@ -286,7 +286,6 @@ async def remove_bond_to_code(config, action_id, template_arg, args):
 | 
			
		||||
async def to_code(config):
 | 
			
		||||
    # Register the loggers this component needs
 | 
			
		||||
    esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
 | 
			
		||||
    cg.add_define("USE_ESP32_BLE_UUID")
 | 
			
		||||
 | 
			
		||||
    var = cg.new_Pvariable(config[CONF_ID])
 | 
			
		||||
    await cg.register_component(var, config)
 | 
			
		||||
 
 | 
			
		||||
@@ -12,30 +12,16 @@ namespace esphome::bluetooth_proxy {
 | 
			
		||||
 | 
			
		||||
static const char *const TAG = "bluetooth_proxy.connection";
 | 
			
		||||
 | 
			
		||||
// This function is allocation-free and directly packs UUIDs into the output array
 | 
			
		||||
// using precalculated constants for the Bluetooth base UUID
 | 
			
		||||
static void fill_128bit_uuid_array(std::array<uint64_t, 2> &out, esp_bt_uuid_t uuid_source) {
 | 
			
		||||
  // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB
 | 
			
		||||
  // out[0] = bytes 8-15 (big-endian)
 | 
			
		||||
  // - For 128-bit UUIDs: use bytes 8-15 as-is
 | 
			
		||||
  // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11
 | 
			
		||||
  out[0] = uuid_source.len == ESP_UUID_LEN_128
 | 
			
		||||
               ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) |
 | 
			
		||||
                  ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) |
 | 
			
		||||
                  ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) |
 | 
			
		||||
                  ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8]))
 | 
			
		||||
               : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32)
 | 
			
		||||
                   << 32) |
 | 
			
		||||
                  0x00001000ULL);  // Base UUID bytes 8-11
 | 
			
		||||
  // out[1] = bytes 0-7 (big-endian)
 | 
			
		||||
  // - For 128-bit UUIDs: use bytes 0-7 as-is
 | 
			
		||||
  // - For 16/32-bit UUIDs: use precalculated base UUID constant
 | 
			
		||||
  out[1] = uuid_source.len == ESP_UUID_LEN_128
 | 
			
		||||
               ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) |
 | 
			
		||||
                     ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) |
 | 
			
		||||
                     ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) |
 | 
			
		||||
                     ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0])
 | 
			
		||||
               : 0x800000805F9B34FBULL;  // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB
 | 
			
		||||
  esp_bt_uuid_t uuid = espbt::ESPBTUUID::from_uuid(uuid_source).as_128bit().get_uuid();
 | 
			
		||||
  out[0] = ((uint64_t) uuid.uuid.uuid128[15] << 56) | ((uint64_t) uuid.uuid.uuid128[14] << 48) |
 | 
			
		||||
           ((uint64_t) uuid.uuid.uuid128[13] << 40) | ((uint64_t) uuid.uuid.uuid128[12] << 32) |
 | 
			
		||||
           ((uint64_t) uuid.uuid.uuid128[11] << 24) | ((uint64_t) uuid.uuid.uuid128[10] << 16) |
 | 
			
		||||
           ((uint64_t) uuid.uuid.uuid128[9] << 8) | ((uint64_t) uuid.uuid.uuid128[8]);
 | 
			
		||||
  out[1] = ((uint64_t) uuid.uuid.uuid128[7] << 56) | ((uint64_t) uuid.uuid.uuid128[6] << 48) |
 | 
			
		||||
           ((uint64_t) uuid.uuid.uuid128[5] << 40) | ((uint64_t) uuid.uuid.uuid128[4] << 32) |
 | 
			
		||||
           ((uint64_t) uuid.uuid.uuid128[3] << 24) | ((uint64_t) uuid.uuid.uuid128[2] << 16) |
 | 
			
		||||
           ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper to fill UUID in the appropriate format based on client support and UUID type
 | 
			
		||||
@@ -133,7 +119,7 @@ void BluetoothConnection::loop() {
 | 
			
		||||
 | 
			
		||||
  // Check if we should disable the loop
 | 
			
		||||
  // - For V3_WITH_CACHE: Services are never sent, disable after INIT state
 | 
			
		||||
  // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
 | 
			
		||||
  // - For other connections: Disable only after service discovery is complete
 | 
			
		||||
  //   (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
 | 
			
		||||
  if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
 | 
			
		||||
                                                   this->send_service_ == DONE_SENDING_SERVICES)) {
 | 
			
		||||
@@ -160,7 +146,10 @@ void BluetoothConnection::send_service_for_discovery_() {
 | 
			
		||||
  if (this->send_service_ >= this->service_count_) {
 | 
			
		||||
    this->send_service_ = DONE_SENDING_SERVICES;
 | 
			
		||||
    this->proxy_->send_gatt_services_done(this->address_);
 | 
			
		||||
    this->release_services();
 | 
			
		||||
    if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
 | 
			
		||||
        this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
 | 
			
		||||
      this->release_services();
 | 
			
		||||
    }
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,8 +7,6 @@
 | 
			
		||||
#include <esphome/components/sensor/sensor.h>
 | 
			
		||||
#include <esphome/core/component.h>
 | 
			
		||||
 | 
			
		||||
#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID"
 | 
			
		||||
 | 
			
		||||
namespace esphome {
 | 
			
		||||
namespace bme280_base {
 | 
			
		||||
 | 
			
		||||
@@ -100,18 +98,18 @@ void BME280Component::setup() {
 | 
			
		||||
 | 
			
		||||
  if (!this->read_byte(BME280_REGISTER_CHIPID, &chip_id)) {
 | 
			
		||||
    this->error_code_ = COMMUNICATION_FAILED;
 | 
			
		||||
    this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  if (chip_id != 0x60) {
 | 
			
		||||
    this->error_code_ = WRONG_CHIP_ID;
 | 
			
		||||
    this->mark_failed(BME280_ERROR_WRONG_CHIP_ID);
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Send a soft reset.
 | 
			
		||||
  if (!this->write_byte(BME280_REGISTER_RESET, BME280_SOFT_RESET)) {
 | 
			
		||||
    this->mark_failed("Reset failed");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  // Wait until the NVM data has finished loading.
 | 
			
		||||
@@ -120,12 +118,14 @@ void BME280Component::setup() {
 | 
			
		||||
  do {  // NOLINT
 | 
			
		||||
    delay(2);
 | 
			
		||||
    if (!this->read_byte(BME280_REGISTER_STATUS, &status)) {
 | 
			
		||||
      this->mark_failed("Error reading status register");
 | 
			
		||||
      ESP_LOGW(TAG, "Error reading status register.");
 | 
			
		||||
      this->mark_failed();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  } while ((status & BME280_STATUS_IM_UPDATE) && (--retry));
 | 
			
		||||
  if (status & BME280_STATUS_IM_UPDATE) {
 | 
			
		||||
    this->mark_failed("Timeout loading NVM");
 | 
			
		||||
    ESP_LOGW(TAG, "Timeout loading NVM.");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -153,26 +153,26 @@ void BME280Component::setup() {
 | 
			
		||||
 | 
			
		||||
  uint8_t humid_control_val = 0;
 | 
			
		||||
  if (!this->read_byte(BME280_REGISTER_CONTROLHUMID, &humid_control_val)) {
 | 
			
		||||
    this->mark_failed("Read humidity control");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  humid_control_val &= ~0b00000111;
 | 
			
		||||
  humid_control_val |= this->humidity_oversampling_ & 0b111;
 | 
			
		||||
  if (!this->write_byte(BME280_REGISTER_CONTROLHUMID, humid_control_val)) {
 | 
			
		||||
    this->mark_failed("Write humidity control");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  uint8_t config_register = 0;
 | 
			
		||||
  if (!this->read_byte(BME280_REGISTER_CONFIG, &config_register)) {
 | 
			
		||||
    this->mark_failed("Read config");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  config_register &= ~0b11111100;
 | 
			
		||||
  config_register |= 0b101 << 5;  // 1000 ms standby time
 | 
			
		||||
  config_register |= (this->iir_filter_ & 0b111) << 2;
 | 
			
		||||
  if (!this->write_byte(BME280_REGISTER_CONFIG, config_register)) {
 | 
			
		||||
    this->mark_failed("Write config");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -183,7 +183,7 @@ void BME280Component::dump_config() {
 | 
			
		||||
      ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
 | 
			
		||||
      break;
 | 
			
		||||
    case WRONG_CHIP_ID:
 | 
			
		||||
      ESP_LOGE(TAG, BME280_ERROR_WRONG_CHIP_ID);
 | 
			
		||||
      ESP_LOGE(TAG, "BME280 has wrong chip ID! Is it a BME280?");
 | 
			
		||||
      break;
 | 
			
		||||
    case NONE:
 | 
			
		||||
    default:
 | 
			
		||||
@@ -223,21 +223,21 @@ void BME280Component::update() {
 | 
			
		||||
  this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() {
 | 
			
		||||
    uint8_t data[8];
 | 
			
		||||
    if (!this->read_bytes(BME280_REGISTER_MEASUREMENTS, data, 8)) {
 | 
			
		||||
      ESP_LOGW(TAG, "Error reading registers");
 | 
			
		||||
      ESP_LOGW(TAG, "Error reading registers.");
 | 
			
		||||
      this->status_set_warning();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    int32_t t_fine = 0;
 | 
			
		||||
    float const temperature = this->read_temperature_(data, &t_fine);
 | 
			
		||||
    if (std::isnan(temperature)) {
 | 
			
		||||
      ESP_LOGW(TAG, "Invalid temperature");
 | 
			
		||||
      ESP_LOGW(TAG, "Invalid temperature, cannot read pressure & humidity values.");
 | 
			
		||||
      this->status_set_warning();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    float const pressure = this->read_pressure_(data, t_fine);
 | 
			
		||||
    float const humidity = this->read_humidity_(data, t_fine);
 | 
			
		||||
 | 
			
		||||
    ESP_LOGV(TAG, "Temperature=%.1f°C Pressure=%.1fhPa Humidity=%.1f%%", temperature, pressure, humidity);
 | 
			
		||||
    ESP_LOGV(TAG, "Got temperature=%.1f°C pressure=%.1fhPa humidity=%.1f%%", temperature, pressure, humidity);
 | 
			
		||||
    if (this->temperature_sensor_ != nullptr)
 | 
			
		||||
      this->temperature_sensor_->publish_state(temperature);
 | 
			
		||||
    if (this->pressure_sensor_ != nullptr)
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,6 @@
 | 
			
		||||
#include "esphome/core/hal.h"
 | 
			
		||||
#include "esphome/core/log.h"
 | 
			
		||||
 | 
			
		||||
#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID"
 | 
			
		||||
 | 
			
		||||
namespace esphome {
 | 
			
		||||
namespace bmp280_base {
 | 
			
		||||
 | 
			
		||||
@@ -65,23 +63,23 @@ void BMP280Component::setup() {
 | 
			
		||||
  // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855
 | 
			
		||||
  if (!this->read_byte(0xD0, &chip_id)) {
 | 
			
		||||
    this->error_code_ = COMMUNICATION_FAILED;
 | 
			
		||||
    this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  if (!this->read_byte(0xD0, &chip_id)) {
 | 
			
		||||
    this->error_code_ = COMMUNICATION_FAILED;
 | 
			
		||||
    this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  if (chip_id != 0x58) {
 | 
			
		||||
    this->error_code_ = WRONG_CHIP_ID;
 | 
			
		||||
    this->mark_failed(BMP280_ERROR_WRONG_CHIP_ID);
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Send a soft reset.
 | 
			
		||||
  if (!this->write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) {
 | 
			
		||||
    this->mark_failed("Reset failed");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  // Wait until the NVM data has finished loading.
 | 
			
		||||
@@ -90,12 +88,14 @@ void BMP280Component::setup() {
 | 
			
		||||
  do {
 | 
			
		||||
    delay(2);
 | 
			
		||||
    if (!this->read_byte(BMP280_REGISTER_STATUS, &status)) {
 | 
			
		||||
      this->mark_failed("Error reading status register");
 | 
			
		||||
      ESP_LOGW(TAG, "Error reading status register.");
 | 
			
		||||
      this->mark_failed();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  } while ((status & BMP280_STATUS_IM_UPDATE) && (--retry));
 | 
			
		||||
  if (status & BMP280_STATUS_IM_UPDATE) {
 | 
			
		||||
    this->mark_failed("Timeout loading NVM");
 | 
			
		||||
    ESP_LOGW(TAG, "Timeout loading NVM.");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -116,14 +116,14 @@ void BMP280Component::setup() {
 | 
			
		||||
 | 
			
		||||
  uint8_t config_register = 0;
 | 
			
		||||
  if (!this->read_byte(BMP280_REGISTER_CONFIG, &config_register)) {
 | 
			
		||||
    this->mark_failed("Read config");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  config_register &= ~0b11111100;
 | 
			
		||||
  config_register |= 0b000 << 5;  // 0.5 ms standby time
 | 
			
		||||
  config_register |= (this->iir_filter_ & 0b111) << 2;
 | 
			
		||||
  if (!this->write_byte(BMP280_REGISTER_CONFIG, config_register)) {
 | 
			
		||||
    this->mark_failed("Write config");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -134,7 +134,7 @@ void BMP280Component::dump_config() {
 | 
			
		||||
      ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
 | 
			
		||||
      break;
 | 
			
		||||
    case WRONG_CHIP_ID:
 | 
			
		||||
      ESP_LOGE(TAG, BMP280_ERROR_WRONG_CHIP_ID);
 | 
			
		||||
      ESP_LOGE(TAG, "BMP280 has wrong chip ID! Is it a BME280?");
 | 
			
		||||
      break;
 | 
			
		||||
    case NONE:
 | 
			
		||||
    default:
 | 
			
		||||
@@ -172,13 +172,13 @@ void BMP280Component::update() {
 | 
			
		||||
    int32_t t_fine = 0;
 | 
			
		||||
    float temperature = this->read_temperature_(&t_fine);
 | 
			
		||||
    if (std::isnan(temperature)) {
 | 
			
		||||
      ESP_LOGW(TAG, "Invalid temperature");
 | 
			
		||||
      ESP_LOGW(TAG, "Invalid temperature, cannot read pressure values.");
 | 
			
		||||
      this->status_set_warning();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    float pressure = this->read_pressure_(t_fine);
 | 
			
		||||
 | 
			
		||||
    ESP_LOGV(TAG, "Temperature=%.1f°C Pressure=%.1fhPa", temperature, pressure);
 | 
			
		||||
    ESP_LOGD(TAG, "Got temperature=%.1f°C pressure=%.1fhPa", temperature, pressure);
 | 
			
		||||
    if (this->temperature_sensor_ != nullptr)
 | 
			
		||||
      this->temperature_sensor_->publish_state(temperature);
 | 
			
		||||
    if (this->pressure_sensor_ != nullptr)
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ void DelonghiClimate::transmit_state() {
 | 
			
		||||
 | 
			
		||||
  data->mark(DELONGHI_HEADER_MARK);
 | 
			
		||||
  data->space(DELONGHI_HEADER_SPACE);
 | 
			
		||||
  for (unsigned char b : remote_state) {
 | 
			
		||||
  for (uint8_t b : remote_state) {
 | 
			
		||||
    for (uint8_t mask = 1; mask > 0; mask <<= 1) {  // iterate through bit mask
 | 
			
		||||
      data->mark(DELONGHI_BIT_MARK);
 | 
			
		||||
      bool bit = b & mask;
 | 
			
		||||
 
 | 
			
		||||
@@ -824,9 +824,8 @@ async def to_code(config):
 | 
			
		||||
    cg.set_cpp_standard("gnu++20")
 | 
			
		||||
    cg.add_build_flag("-DUSE_ESP32")
 | 
			
		||||
    cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
 | 
			
		||||
    variant = config[CONF_VARIANT]
 | 
			
		||||
    cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
 | 
			
		||||
    cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
 | 
			
		||||
    cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{config[CONF_VARIANT]}")
 | 
			
		||||
    cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[config[CONF_VARIANT]])
 | 
			
		||||
    cg.add_define(ThreadModel.MULTI_ATOMICS)
 | 
			
		||||
 | 
			
		||||
    cg.add_platformio_option("lib_ldf_mode", "off")
 | 
			
		||||
@@ -860,7 +859,6 @@ async def to_code(config):
 | 
			
		||||
        cg.add_platformio_option(
 | 
			
		||||
            "platform_packages", ["espressif/toolchain-esp32ulp@2.35.0-20220830"]
 | 
			
		||||
        )
 | 
			
		||||
        add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True)
 | 
			
		||||
        add_idf_sdkconfig_option(
 | 
			
		||||
            f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -294,7 +294,6 @@ async def to_code(config):
 | 
			
		||||
 | 
			
		||||
    if config[CONF_ADVERTISING]:
 | 
			
		||||
        cg.add_define("USE_ESP32_BLE_ADVERTISING")
 | 
			
		||||
        cg.add_define("USE_ESP32_BLE_UUID")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({}))
 | 
			
		||||
 
 | 
			
		||||
@@ -306,7 +306,7 @@ void ESP32BLE::loop() {
 | 
			
		||||
      case BLEEvent::GATTS: {
 | 
			
		||||
        esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event;
 | 
			
		||||
        esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if;
 | 
			
		||||
        esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param;
 | 
			
		||||
        esp_ble_gatts_cb_param_t *param = ble_event->event_.gatts.gatts_param;
 | 
			
		||||
        ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event);
 | 
			
		||||
        for (auto *gatts_handler : this->gatts_event_handlers_) {
 | 
			
		||||
          gatts_handler->gatts_event_handler(event, gatts_if, param);
 | 
			
		||||
@@ -316,7 +316,7 @@ void ESP32BLE::loop() {
 | 
			
		||||
      case BLEEvent::GATTC: {
 | 
			
		||||
        esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event;
 | 
			
		||||
        esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if;
 | 
			
		||||
        esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param;
 | 
			
		||||
        esp_ble_gattc_cb_param_t *param = ble_event->event_.gattc.gattc_param;
 | 
			
		||||
        ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event);
 | 
			
		||||
        for (auto *gattc_handler : this->gattc_event_handlers_) {
 | 
			
		||||
          gattc_handler->gattc_event_handler(event, gattc_if, param);
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,8 @@
 | 
			
		||||
#ifdef USE_ESP32
 | 
			
		||||
 | 
			
		||||
#include <cstddef>  // for offsetof
 | 
			
		||||
#include <cstring>  // for memcpy
 | 
			
		||||
#include <vector>
 | 
			
		||||
 | 
			
		||||
#include <esp_gap_ble_api.h>
 | 
			
		||||
#include <esp_gattc_api.h>
 | 
			
		||||
#include <esp_gatts_api.h>
 | 
			
		||||
@@ -61,24 +62,10 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.rssi) == sizeof(es
 | 
			
		||||
static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == sizeof(esp_bt_status_t) + sizeof(int8_t),
 | 
			
		||||
              "remote_addr must follow rssi in read_rssi_cmpl");
 | 
			
		||||
 | 
			
		||||
// Param struct sizes on ESP32
 | 
			
		||||
static constexpr size_t GATTC_PARAM_SIZE = 28;
 | 
			
		||||
static constexpr size_t GATTS_PARAM_SIZE = 32;
 | 
			
		||||
 | 
			
		||||
// Maximum size for inline storage of data
 | 
			
		||||
// GATTC: 80 - 28 (param) - 8 (other fields) = 44 bytes for data
 | 
			
		||||
// GATTS: 80 - 32 (param) - 8 (other fields) = 40 bytes for data
 | 
			
		||||
static constexpr size_t GATTC_INLINE_DATA_SIZE = 44;
 | 
			
		||||
static constexpr size_t GATTS_INLINE_DATA_SIZE = 40;
 | 
			
		||||
 | 
			
		||||
// Verify param struct sizes
 | 
			
		||||
static_assert(sizeof(esp_ble_gattc_cb_param_t) == GATTC_PARAM_SIZE, "GATTC param size unexpected");
 | 
			
		||||
static_assert(sizeof(esp_ble_gatts_cb_param_t) == GATTS_PARAM_SIZE, "GATTS param size unexpected");
 | 
			
		||||
 | 
			
		||||
// Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop().
 | 
			
		||||
// This class stores each event with minimal memory usage.
 | 
			
		||||
// GAP events (99% of traffic) don't have the heap allocation overhead.
 | 
			
		||||
// GATTC/GATTS events use heap allocation for their param and inline storage for small data.
 | 
			
		||||
// GAP events (99% of traffic) don't have the vector overhead.
 | 
			
		||||
// GATTC/GATTS events use heap allocation for their param and data.
 | 
			
		||||
//
 | 
			
		||||
// Event flow:
 | 
			
		||||
// 1. ESP-IDF BLE stack calls our static handlers in the BLE task context
 | 
			
		||||
@@ -125,21 +112,21 @@ class BLEEvent {
 | 
			
		||||
    this->init_gap_data_(e, p);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Constructor for GATTC events - param stored inline, data may use heap
 | 
			
		||||
  // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF
 | 
			
		||||
  // is only valid during the callback execution. Since BLE events are processed
 | 
			
		||||
  // asynchronously in the main loop, we store our own copy inline to ensure
 | 
			
		||||
  // the data remains valid until the event is processed.
 | 
			
		||||
  // Constructor for GATTC events - uses heap allocation
 | 
			
		||||
  // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization.
 | 
			
		||||
  // The param pointer from ESP-IDF is only valid during the callback execution.
 | 
			
		||||
  // Since BLE events are processed asynchronously in the main loop, we must create
 | 
			
		||||
  // our own copy to ensure the data remains valid until the event is processed.
 | 
			
		||||
  BLEEvent(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) {
 | 
			
		||||
    this->type_ = GATTC;
 | 
			
		||||
    this->init_gattc_data_(e, i, p);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Constructor for GATTS events - param stored inline, data may use heap
 | 
			
		||||
  // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF
 | 
			
		||||
  // is only valid during the callback execution. Since BLE events are processed
 | 
			
		||||
  // asynchronously in the main loop, we store our own copy inline to ensure
 | 
			
		||||
  // the data remains valid until the event is processed.
 | 
			
		||||
  // Constructor for GATTS events - uses heap allocation
 | 
			
		||||
  // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization.
 | 
			
		||||
  // The param pointer from ESP-IDF is only valid during the callback execution.
 | 
			
		||||
  // Since BLE events are processed asynchronously in the main loop, we must create
 | 
			
		||||
  // our own copy to ensure the data remains valid until the event is processed.
 | 
			
		||||
  BLEEvent(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) {
 | 
			
		||||
    this->type_ = GATTS;
 | 
			
		||||
    this->init_gatts_data_(e, i, p);
 | 
			
		||||
@@ -149,32 +136,25 @@ class BLEEvent {
 | 
			
		||||
  ~BLEEvent() { this->release(); }
 | 
			
		||||
 | 
			
		||||
  // Default constructor for pre-allocation in pool
 | 
			
		||||
  BLEEvent() : event_{}, type_(GAP) {}
 | 
			
		||||
  BLEEvent() : type_(GAP) {}
 | 
			
		||||
 | 
			
		||||
  // Invoked on return to EventPool - clean up any heap-allocated data
 | 
			
		||||
  void release() {
 | 
			
		||||
    switch (this->type_) {
 | 
			
		||||
      case GAP:
 | 
			
		||||
        // GAP events don't have heap allocations
 | 
			
		||||
        break;
 | 
			
		||||
      case GATTC:
 | 
			
		||||
        // Param is now stored inline, only delete heap data if it was heap-allocated
 | 
			
		||||
        if (!this->event_.gattc.is_inline && this->event_.gattc.data.heap_data != nullptr) {
 | 
			
		||||
          delete[] this->event_.gattc.data.heap_data;
 | 
			
		||||
        }
 | 
			
		||||
        // Clear critical fields to prevent issues if type changes
 | 
			
		||||
        this->event_.gattc.is_inline = false;
 | 
			
		||||
        this->event_.gattc.data.heap_data = nullptr;
 | 
			
		||||
        break;
 | 
			
		||||
      case GATTS:
 | 
			
		||||
        // Param is now stored inline, only delete heap data if it was heap-allocated
 | 
			
		||||
        if (!this->event_.gatts.is_inline && this->event_.gatts.data.heap_data != nullptr) {
 | 
			
		||||
          delete[] this->event_.gatts.data.heap_data;
 | 
			
		||||
        }
 | 
			
		||||
        // Clear critical fields to prevent issues if type changes
 | 
			
		||||
        this->event_.gatts.is_inline = false;
 | 
			
		||||
        this->event_.gatts.data.heap_data = nullptr;
 | 
			
		||||
        break;
 | 
			
		||||
    if (this->type_ == GAP) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (this->type_ == GATTC) {
 | 
			
		||||
      delete this->event_.gattc.gattc_param;
 | 
			
		||||
      delete this->event_.gattc.data;
 | 
			
		||||
      this->event_.gattc.gattc_param = nullptr;
 | 
			
		||||
      this->event_.gattc.data = nullptr;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (this->type_ == GATTS) {
 | 
			
		||||
      delete this->event_.gatts.gatts_param;
 | 
			
		||||
      delete this->event_.gatts.data;
 | 
			
		||||
      this->event_.gatts.gatts_param = nullptr;
 | 
			
		||||
      this->event_.gatts.data = nullptr;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -226,30 +206,20 @@ class BLEEvent {
 | 
			
		||||
 | 
			
		||||
    // NOLINTNEXTLINE(readability-identifier-naming)
 | 
			
		||||
    struct gattc_event {
 | 
			
		||||
      esp_ble_gattc_cb_param_t gattc_param;  // Stored inline (28 bytes)
 | 
			
		||||
      esp_gattc_cb_event_t gattc_event;      // 4 bytes
 | 
			
		||||
      union {
 | 
			
		||||
        uint8_t *heap_data;                           // 4 bytes when heap-allocated
 | 
			
		||||
        uint8_t inline_data[GATTC_INLINE_DATA_SIZE];  // 44 bytes when stored inline
 | 
			
		||||
      } data;                                         // 44 bytes total
 | 
			
		||||
      uint16_t data_len;                              // 2 bytes
 | 
			
		||||
      esp_gatt_if_t gattc_if;                         // 1 byte
 | 
			
		||||
      bool is_inline;                                 // 1 byte - true when data is stored inline
 | 
			
		||||
    } gattc;                                          // Total: 80 bytes
 | 
			
		||||
      esp_gattc_cb_event_t gattc_event;
 | 
			
		||||
      esp_gatt_if_t gattc_if;
 | 
			
		||||
      esp_ble_gattc_cb_param_t *gattc_param;  // Heap-allocated
 | 
			
		||||
      std::vector<uint8_t> *data;             // Heap-allocated
 | 
			
		||||
    } gattc;                                  // 16 bytes (pointers only)
 | 
			
		||||
 | 
			
		||||
    // NOLINTNEXTLINE(readability-identifier-naming)
 | 
			
		||||
    struct gatts_event {
 | 
			
		||||
      esp_ble_gatts_cb_param_t gatts_param;  // Stored inline (32 bytes)
 | 
			
		||||
      esp_gatts_cb_event_t gatts_event;      // 4 bytes
 | 
			
		||||
      union {
 | 
			
		||||
        uint8_t *heap_data;                           // 4 bytes when heap-allocated
 | 
			
		||||
        uint8_t inline_data[GATTS_INLINE_DATA_SIZE];  // 40 bytes when stored inline
 | 
			
		||||
      } data;                                         // 40 bytes total
 | 
			
		||||
      uint16_t data_len;                              // 2 bytes
 | 
			
		||||
      esp_gatt_if_t gatts_if;                         // 1 byte
 | 
			
		||||
      bool is_inline;                                 // 1 byte - true when data is stored inline
 | 
			
		||||
    } gatts;                                          // Total: 80 bytes
 | 
			
		||||
  } event_;                                           // 80 bytes
 | 
			
		||||
      esp_gatts_cb_event_t gatts_event;
 | 
			
		||||
      esp_gatt_if_t gatts_if;
 | 
			
		||||
      esp_ble_gatts_cb_param_t *gatts_param;  // Heap-allocated
 | 
			
		||||
      std::vector<uint8_t> *data;             // Heap-allocated
 | 
			
		||||
    } gatts;                                  // 16 bytes (pointers only)
 | 
			
		||||
  } event_;                                   // 80 bytes
 | 
			
		||||
 | 
			
		||||
  ble_event_t type_;
 | 
			
		||||
 | 
			
		||||
@@ -263,29 +233,6 @@ class BLEEvent {
 | 
			
		||||
  const esp_ble_sec_t &security() const { return event_.gap.security; }
 | 
			
		||||
 | 
			
		||||
 private:
 | 
			
		||||
  // Helper to copy data with inline storage optimization
 | 
			
		||||
  template<typename EventStruct, size_t InlineSize>
 | 
			
		||||
  void copy_data_with_inline_storage_(EventStruct &event, const uint8_t *src_data, uint16_t len,
 | 
			
		||||
                                      uint8_t **param_value_ptr) {
 | 
			
		||||
    event.data_len = len;
 | 
			
		||||
    if (len > 0) {
 | 
			
		||||
      if (len <= InlineSize) {
 | 
			
		||||
        event.is_inline = true;
 | 
			
		||||
        memcpy(event.data.inline_data, src_data, len);
 | 
			
		||||
        *param_value_ptr = event.data.inline_data;
 | 
			
		||||
      } else {
 | 
			
		||||
        event.is_inline = false;
 | 
			
		||||
        event.data.heap_data = new uint8_t[len];
 | 
			
		||||
        memcpy(event.data.heap_data, src_data, len);
 | 
			
		||||
        *param_value_ptr = event.data.heap_data;
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      event.is_inline = false;
 | 
			
		||||
      event.data.heap_data = nullptr;
 | 
			
		||||
      *param_value_ptr = nullptr;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Initialize GAP event data
 | 
			
		||||
  void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) {
 | 
			
		||||
    this->event_.gap.gap_event = e;
 | 
			
		||||
@@ -370,38 +317,35 @@ class BLEEvent {
 | 
			
		||||
    this->event_.gattc.gattc_if = i;
 | 
			
		||||
 | 
			
		||||
    if (p == nullptr) {
 | 
			
		||||
      // Zero out the param struct when null
 | 
			
		||||
      memset(&this->event_.gattc.gattc_param, 0, sizeof(this->event_.gattc.gattc_param));
 | 
			
		||||
      this->event_.gattc.is_inline = false;
 | 
			
		||||
      this->event_.gattc.data.heap_data = nullptr;
 | 
			
		||||
      this->event_.gattc.data_len = 0;
 | 
			
		||||
      this->event_.gattc.gattc_param = nullptr;
 | 
			
		||||
      this->event_.gattc.data = nullptr;
 | 
			
		||||
      return;  // Invalid event, but we can't log in header file
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Copy param struct inline (no heap allocation!)
 | 
			
		||||
    // GATTC/GATTS events are rare (<1% of events) but we can still store them inline
 | 
			
		||||
    // along with small data payloads, eliminating all heap allocations for typical BLE operations
 | 
			
		||||
    // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer
 | 
			
		||||
    // is only valid during the callback and will be reused/freed after we return
 | 
			
		||||
    this->event_.gattc.gattc_param = *p;
 | 
			
		||||
    // Heap-allocate param and data
 | 
			
		||||
    // Heap allocation is used because GATTC/GATTS events are rare (<1% of events)
 | 
			
		||||
    // while GAP events (99%) are stored inline to minimize memory usage
 | 
			
		||||
    // IMPORTANT: This heap allocation provides clear ownership semantics:
 | 
			
		||||
    // - The BLEEvent owns the allocated memory for its lifetime
 | 
			
		||||
    // - The data remains valid from the BLE callback context until processed in the main loop
 | 
			
		||||
    // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory
 | 
			
		||||
    this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p);
 | 
			
		||||
 | 
			
		||||
    // Copy data for events that need it
 | 
			
		||||
    // The param struct contains pointers (e.g., notify.value) that point to temporary buffers.
 | 
			
		||||
    // We must copy this data to ensure it remains valid when the event is processed later.
 | 
			
		||||
    switch (e) {
 | 
			
		||||
      case ESP_GATTC_NOTIFY_EVT:
 | 
			
		||||
        copy_data_with_inline_storage_<decltype(this->event_.gattc), GATTC_INLINE_DATA_SIZE>(
 | 
			
		||||
            this->event_.gattc, p->notify.value, p->notify.value_len, &this->event_.gattc.gattc_param.notify.value);
 | 
			
		||||
        this->event_.gattc.data = new std::vector<uint8_t>(p->notify.value, p->notify.value + p->notify.value_len);
 | 
			
		||||
        this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data();
 | 
			
		||||
        break;
 | 
			
		||||
      case ESP_GATTC_READ_CHAR_EVT:
 | 
			
		||||
      case ESP_GATTC_READ_DESCR_EVT:
 | 
			
		||||
        copy_data_with_inline_storage_<decltype(this->event_.gattc), GATTC_INLINE_DATA_SIZE>(
 | 
			
		||||
            this->event_.gattc, p->read.value, p->read.value_len, &this->event_.gattc.gattc_param.read.value);
 | 
			
		||||
        this->event_.gattc.data = new std::vector<uint8_t>(p->read.value, p->read.value + p->read.value_len);
 | 
			
		||||
        this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data();
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        this->event_.gattc.is_inline = false;
 | 
			
		||||
        this->event_.gattc.data.heap_data = nullptr;
 | 
			
		||||
        this->event_.gattc.data_len = 0;
 | 
			
		||||
        this->event_.gattc.data = nullptr;
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -412,33 +356,30 @@ class BLEEvent {
 | 
			
		||||
    this->event_.gatts.gatts_if = i;
 | 
			
		||||
 | 
			
		||||
    if (p == nullptr) {
 | 
			
		||||
      // Zero out the param struct when null
 | 
			
		||||
      memset(&this->event_.gatts.gatts_param, 0, sizeof(this->event_.gatts.gatts_param));
 | 
			
		||||
      this->event_.gatts.is_inline = false;
 | 
			
		||||
      this->event_.gatts.data.heap_data = nullptr;
 | 
			
		||||
      this->event_.gatts.data_len = 0;
 | 
			
		||||
      this->event_.gatts.gatts_param = nullptr;
 | 
			
		||||
      this->event_.gatts.data = nullptr;
 | 
			
		||||
      return;  // Invalid event, but we can't log in header file
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Copy param struct inline (no heap allocation!)
 | 
			
		||||
    // GATTC/GATTS events are rare (<1% of events) but we can still store them inline
 | 
			
		||||
    // along with small data payloads, eliminating all heap allocations for typical BLE operations
 | 
			
		||||
    // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer
 | 
			
		||||
    // is only valid during the callback and will be reused/freed after we return
 | 
			
		||||
    this->event_.gatts.gatts_param = *p;
 | 
			
		||||
    // Heap-allocate param and data
 | 
			
		||||
    // Heap allocation is used because GATTC/GATTS events are rare (<1% of events)
 | 
			
		||||
    // while GAP events (99%) are stored inline to minimize memory usage
 | 
			
		||||
    // IMPORTANT: This heap allocation provides clear ownership semantics:
 | 
			
		||||
    // - The BLEEvent owns the allocated memory for its lifetime
 | 
			
		||||
    // - The data remains valid from the BLE callback context until processed in the main loop
 | 
			
		||||
    // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory
 | 
			
		||||
    this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p);
 | 
			
		||||
 | 
			
		||||
    // Copy data for events that need it
 | 
			
		||||
    // The param struct contains pointers (e.g., write.value) that point to temporary buffers.
 | 
			
		||||
    // We must copy this data to ensure it remains valid when the event is processed later.
 | 
			
		||||
    switch (e) {
 | 
			
		||||
      case ESP_GATTS_WRITE_EVT:
 | 
			
		||||
        copy_data_with_inline_storage_<decltype(this->event_.gatts), GATTS_INLINE_DATA_SIZE>(
 | 
			
		||||
            this->event_.gatts, p->write.value, p->write.len, &this->event_.gatts.gatts_param.write.value);
 | 
			
		||||
        this->event_.gatts.data = new std::vector<uint8_t>(p->write.value, p->write.value + p->write.len);
 | 
			
		||||
        this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data();
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        this->event_.gatts.is_inline = false;
 | 
			
		||||
        this->event_.gatts.data.heap_data = nullptr;
 | 
			
		||||
        this->event_.gatts.data_len = 0;
 | 
			
		||||
        this->event_.gatts.data = nullptr;
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -448,15 +389,6 @@ class BLEEvent {
 | 
			
		||||
// The gap member in the union should be 80 bytes (including the gap_event enum)
 | 
			
		||||
static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)) <= 80, "gap_event struct has grown beyond 80 bytes");
 | 
			
		||||
 | 
			
		||||
// Verify GATTC and GATTS structs don't exceed GAP struct size
 | 
			
		||||
// This ensures the union size is determined by GAP (the most common event type)
 | 
			
		||||
static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gattc)) <=
 | 
			
		||||
                  sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)),
 | 
			
		||||
              "gattc_event struct exceeds gap_event size - union size would increase");
 | 
			
		||||
static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gatts)) <=
 | 
			
		||||
                  sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)),
 | 
			
		||||
              "gatts_event struct exceeds gap_event size - union size would increase");
 | 
			
		||||
 | 
			
		||||
// Verify esp_ble_sec_t fits within our union
 | 
			
		||||
static_assert(sizeof(esp_ble_sec_t) <= 73, "esp_ble_sec_t is larger than BLEScanResult");
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
#include "ble_uuid.h"
 | 
			
		||||
 | 
			
		||||
#ifdef USE_ESP32
 | 
			
		||||
#ifdef USE_ESP32_BLE_UUID
 | 
			
		||||
 | 
			
		||||
#include <cstring>
 | 
			
		||||
#include <cstdio>
 | 
			
		||||
@@ -191,5 +190,4 @@ std::string ESPBTUUID::to_string() const {
 | 
			
		||||
 | 
			
		||||
}  // namespace esphome::esp32_ble
 | 
			
		||||
 | 
			
		||||
#endif  // USE_ESP32_BLE_UUID
 | 
			
		||||
#endif  // USE_ESP32
 | 
			
		||||
#endif
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,9 @@
 | 
			
		||||
#pragma once
 | 
			
		||||
 | 
			
		||||
#include "esphome/core/defines.h"
 | 
			
		||||
#include "esphome/core/hal.h"
 | 
			
		||||
#include "esphome/core/helpers.h"
 | 
			
		||||
 | 
			
		||||
#ifdef USE_ESP32
 | 
			
		||||
#ifdef USE_ESP32_BLE_UUID
 | 
			
		||||
 | 
			
		||||
#include <string>
 | 
			
		||||
#include <esp_bt_defs.h>
 | 
			
		||||
@@ -44,5 +42,4 @@ class ESPBTUUID {
 | 
			
		||||
 | 
			
		||||
}  // namespace esphome::esp32_ble
 | 
			
		||||
 | 
			
		||||
#endif  // USE_ESP32_BLE_UUID
 | 
			
		||||
#endif  // USE_ESP32
 | 
			
		||||
#endif
 | 
			
		||||
 
 | 
			
		||||
@@ -65,8 +65,6 @@ FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def to_code(config):
 | 
			
		||||
    cg.add_define("USE_ESP32_BLE_UUID")
 | 
			
		||||
 | 
			
		||||
    uuid = config[CONF_UUID].hex
 | 
			
		||||
    uuid_arr = [
 | 
			
		||||
        cg.RawExpression(f"0x{uuid[i : i + 2]}") for i in range(0, len(uuid), 2)
 | 
			
		||||
 
 | 
			
		||||
@@ -529,7 +529,6 @@ async def to_code_characteristic(service_var, char_conf):
 | 
			
		||||
async def to_code(config):
 | 
			
		||||
    # Register the loggers this component needs
 | 
			
		||||
    esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
 | 
			
		||||
    cg.add_define("USE_ESP32_BLE_UUID")
 | 
			
		||||
 | 
			
		||||
    var = cg.new_Pvariable(config[CONF_ID])
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -373,7 +373,6 @@ async def _add_ble_features():
 | 
			
		||||
    # Add feature-specific defines based on what's needed
 | 
			
		||||
    if BLEFeatures.ESP_BT_DEVICE in _required_features:
 | 
			
		||||
        cg.add_define("USE_ESP32_BLE_DEVICE")
 | 
			
		||||
        cg.add_define("USE_ESP32_BLE_UUID")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema(
 | 
			
		||||
 
 | 
			
		||||
@@ -33,12 +33,10 @@ enum AdvertisementParserType {
 | 
			
		||||
  RAW_ADVERTISEMENTS,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
#ifdef USE_ESP32_BLE_UUID
 | 
			
		||||
struct ServiceData {
 | 
			
		||||
  ESPBTUUID uuid;
 | 
			
		||||
  adv_data_t data;
 | 
			
		||||
};
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
#ifdef USE_ESP32_BLE_DEVICE
 | 
			
		||||
class ESPBLEiBeacon {
 | 
			
		||||
 
 | 
			
		||||
@@ -208,11 +208,11 @@ void ESPNowComponent::enable_() {
 | 
			
		||||
  esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL);
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
  this->state_ = ESPNOW_STATE_ENABLED;
 | 
			
		||||
 | 
			
		||||
  for (auto peer : this->peers_) {
 | 
			
		||||
    this->add_peer(peer.address);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  this->state_ = ESPNOW_STATE_ENABLED;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void ESPNowComponent::disable() {
 | 
			
		||||
@@ -407,7 +407,7 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) {
 | 
			
		||||
    this->status_momentary_warning("peer-add-failed");
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    return ESP_ERR_INVALID_MAC;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -17,8 +17,9 @@ CONFIG_SCHEMA = cv.All(
 | 
			
		||||
    cv.Schema(
 | 
			
		||||
        {
 | 
			
		||||
            cv.GenerateID(): cv.declare_id(LD2450Component),
 | 
			
		||||
            cv.Optional(CONF_THROTTLE): cv.invalid(
 | 
			
		||||
                f"{CONF_THROTTLE} has been removed; use per-sensor filters, instead"
 | 
			
		||||
            cv.Optional(CONF_THROTTLE, default="1000ms"): cv.All(
 | 
			
		||||
                cv.positive_time_period_milliseconds,
 | 
			
		||||
                cv.Range(min=cv.TimePeriod(milliseconds=1)),
 | 
			
		||||
            ),
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
@@ -45,3 +46,4 @@ async def to_code(config):
 | 
			
		||||
    var = cg.new_Pvariable(config[CONF_ID])
 | 
			
		||||
    await cg.register_component(var, config)
 | 
			
		||||
    await uart.register_uart_device(var, config)
 | 
			
		||||
    cg.add(var.set_throttle(config[CONF_THROTTLE]))
 | 
			
		||||
 
 | 
			
		||||
@@ -21,17 +21,14 @@ CONFIG_SCHEMA = {
 | 
			
		||||
    cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component),
 | 
			
		||||
    cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema(
 | 
			
		||||
        device_class=DEVICE_CLASS_OCCUPANCY,
 | 
			
		||||
        filters=[{"settle": cv.TimePeriod(milliseconds=1000)}],
 | 
			
		||||
        icon=ICON_SHIELD_ACCOUNT,
 | 
			
		||||
    ),
 | 
			
		||||
    cv.Optional(CONF_HAS_MOVING_TARGET): binary_sensor.binary_sensor_schema(
 | 
			
		||||
        device_class=DEVICE_CLASS_MOTION,
 | 
			
		||||
        filters=[{"settle": cv.TimePeriod(milliseconds=1000)}],
 | 
			
		||||
        icon=ICON_TARGET_ACCOUNT,
 | 
			
		||||
    ),
 | 
			
		||||
    cv.Optional(CONF_HAS_STILL_TARGET): binary_sensor.binary_sensor_schema(
 | 
			
		||||
        device_class=DEVICE_CLASS_OCCUPANCY,
 | 
			
		||||
        filters=[{"settle": cv.TimePeriod(milliseconds=1000)}],
 | 
			
		||||
        icon=ICON_MEDITATION,
 | 
			
		||||
    ),
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -199,8 +199,9 @@ void LD2450Component::dump_config() {
 | 
			
		||||
  ESP_LOGCONFIG(TAG,
 | 
			
		||||
                "LD2450:\n"
 | 
			
		||||
                "  Firmware version: %s\n"
 | 
			
		||||
                "  MAC address: %s",
 | 
			
		||||
                version.c_str(), mac_str.c_str());
 | 
			
		||||
                "  MAC address: %s\n"
 | 
			
		||||
                "  Throttle: %u ms",
 | 
			
		||||
                version.c_str(), mac_str.c_str(), this->throttle_);
 | 
			
		||||
#ifdef USE_BINARY_SENSOR
 | 
			
		||||
  ESP_LOGCONFIG(TAG, "Binary Sensors:");
 | 
			
		||||
  LOG_BINARY_SENSOR("  ", "MovingTarget", this->moving_target_binary_sensor_);
 | 
			
		||||
@@ -430,6 +431,11 @@ void LD2450Component::send_command_(uint8_t command, const uint8_t *command_valu
 | 
			
		||||
//  [AA FF 03 00] [0E 03 B1 86 10 00 40 01] [00 00 00 00 00 00 00 00] [00 00 00 00 00 00 00 00] [55 CC]
 | 
			
		||||
//   Header       Target 1                  Target 2                  Target 3                  End
 | 
			
		||||
void LD2450Component::handle_periodic_data_() {
 | 
			
		||||
  // Early throttle check - moved before any processing to save CPU cycles
 | 
			
		||||
  if (App.get_loop_component_start_time() - this->last_periodic_millis_ < this->throttle_) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (this->buffer_pos_ < 29) {  // header (4 bytes) + 8 x 3 target data + footer (2 bytes)
 | 
			
		||||
    ESP_LOGE(TAG, "Invalid length");
 | 
			
		||||
    return;
 | 
			
		||||
@@ -440,6 +446,8 @@ void LD2450Component::handle_periodic_data_() {
 | 
			
		||||
    ESP_LOGE(TAG, "Invalid header/footer");
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  // Save the timestamp after validating the frame so, if invalid, we'll take the next frame immediately
 | 
			
		||||
  this->last_periodic_millis_ = App.get_loop_component_start_time();
 | 
			
		||||
 | 
			
		||||
  int16_t target_count = 0;
 | 
			
		||||
  int16_t still_target_count = 0;
 | 
			
		||||
 
 | 
			
		||||
@@ -110,6 +110,7 @@ class LD2450Component : public Component, public uart::UARTDevice {
 | 
			
		||||
  void dump_config() override;
 | 
			
		||||
  void loop() override;
 | 
			
		||||
  void set_presence_timeout();
 | 
			
		||||
  void set_throttle(uint16_t value) { this->throttle_ = value; }
 | 
			
		||||
  void read_all_info();
 | 
			
		||||
  void query_zone_info();
 | 
			
		||||
  void restart_and_read_all_info();
 | 
			
		||||
@@ -160,9 +161,11 @@ class LD2450Component : public Component, public uart::UARTDevice {
 | 
			
		||||
  bool get_timeout_status_(uint32_t check_millis);
 | 
			
		||||
  uint8_t count_targets_in_zone_(const Zone &zone, bool is_moving);
 | 
			
		||||
 | 
			
		||||
  uint32_t last_periodic_millis_ = 0;
 | 
			
		||||
  uint32_t presence_millis_ = 0;
 | 
			
		||||
  uint32_t still_presence_millis_ = 0;
 | 
			
		||||
  uint32_t moving_presence_millis_ = 0;
 | 
			
		||||
  uint16_t throttle_ = 0;
 | 
			
		||||
  uint16_t timeout_ = 5;
 | 
			
		||||
  uint8_t buffer_data_[MAX_LINE_LENGTH];
 | 
			
		||||
  uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
 | 
			
		||||
 
 | 
			
		||||
@@ -42,43 +42,16 @@ CONFIG_SCHEMA = cv.Schema(
 | 
			
		||||
    {
 | 
			
		||||
        cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component),
 | 
			
		||||
        cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
 | 
			
		||||
            accuracy_decimals=0,
 | 
			
		||||
            filters=[
 | 
			
		||||
                {
 | 
			
		||||
                    "timeout": {
 | 
			
		||||
                        "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                        "value": "last",
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
            ],
 | 
			
		||||
            icon=ICON_ACCOUNT_GROUP,
 | 
			
		||||
            accuracy_decimals=0,
 | 
			
		||||
        ),
 | 
			
		||||
        cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema(
 | 
			
		||||
            accuracy_decimals=0,
 | 
			
		||||
            filters=[
 | 
			
		||||
                {
 | 
			
		||||
                    "timeout": {
 | 
			
		||||
                        "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                        "value": "last",
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
            ],
 | 
			
		||||
            icon=ICON_HUMAN_GREETING_PROXIMITY,
 | 
			
		||||
            accuracy_decimals=0,
 | 
			
		||||
        ),
 | 
			
		||||
        cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema(
 | 
			
		||||
            accuracy_decimals=0,
 | 
			
		||||
            filters=[
 | 
			
		||||
                {
 | 
			
		||||
                    "timeout": {
 | 
			
		||||
                        "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                        "value": "last",
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
            ],
 | 
			
		||||
            icon=ICON_ACCOUNT_SWITCH,
 | 
			
		||||
            accuracy_decimals=0,
 | 
			
		||||
        ),
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
@@ -89,86 +62,32 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
 | 
			
		||||
            {
 | 
			
		||||
                cv.Optional(CONF_X): sensor.sensor_schema(
 | 
			
		||||
                    device_class=DEVICE_CLASS_DISTANCE,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_ALPHA_X_BOX_OUTLINE,
 | 
			
		||||
                    unit_of_measurement=UNIT_MILLIMETER,
 | 
			
		||||
                    icon=ICON_ALPHA_X_BOX_OUTLINE,
 | 
			
		||||
                ),
 | 
			
		||||
                cv.Optional(CONF_Y): sensor.sensor_schema(
 | 
			
		||||
                    device_class=DEVICE_CLASS_DISTANCE,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_ALPHA_Y_BOX_OUTLINE,
 | 
			
		||||
                    unit_of_measurement=UNIT_MILLIMETER,
 | 
			
		||||
                    icon=ICON_ALPHA_Y_BOX_OUTLINE,
 | 
			
		||||
                ),
 | 
			
		||||
                cv.Optional(CONF_SPEED): sensor.sensor_schema(
 | 
			
		||||
                    device_class=DEVICE_CLASS_SPEED,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_SPEEDOMETER_SLOW,
 | 
			
		||||
                    unit_of_measurement=UNIT_MILLIMETER_PER_SECOND,
 | 
			
		||||
                    icon=ICON_SPEEDOMETER_SLOW,
 | 
			
		||||
                ),
 | 
			
		||||
                cv.Optional(CONF_ANGLE): sensor.sensor_schema(
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP,
 | 
			
		||||
                    unit_of_measurement=UNIT_DEGREES,
 | 
			
		||||
                    icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP,
 | 
			
		||||
                ),
 | 
			
		||||
                cv.Optional(CONF_DISTANCE): sensor.sensor_schema(
 | 
			
		||||
                    device_class=DEVICE_CLASS_DISTANCE,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_MAP_MARKER_DISTANCE,
 | 
			
		||||
                    unit_of_measurement=UNIT_MILLIMETER,
 | 
			
		||||
                    icon=ICON_MAP_MARKER_DISTANCE,
 | 
			
		||||
                ),
 | 
			
		||||
                cv.Optional(CONF_RESOLUTION): sensor.sensor_schema(
 | 
			
		||||
                    device_class=DEVICE_CLASS_DISTANCE,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_RELATION_ZERO_OR_ONE_TO_ZERO_OR_ONE,
 | 
			
		||||
                    unit_of_measurement=UNIT_MILLIMETER,
 | 
			
		||||
                    icon=ICON_RELATION_ZERO_OR_ONE_TO_ZERO_OR_ONE,
 | 
			
		||||
                ),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
@@ -178,43 +97,16 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
 | 
			
		||||
        cv.Optional(f"zone_{n + 1}"): cv.Schema(
 | 
			
		||||
            {
 | 
			
		||||
                cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
 | 
			
		||||
                    accuracy_decimals=0,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_MAP_MARKER_ACCOUNT,
 | 
			
		||||
                    accuracy_decimals=0,
 | 
			
		||||
                ),
 | 
			
		||||
                cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema(
 | 
			
		||||
                    accuracy_decimals=0,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_MAP_MARKER_ACCOUNT,
 | 
			
		||||
                    accuracy_decimals=0,
 | 
			
		||||
                ),
 | 
			
		||||
                cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema(
 | 
			
		||||
                    accuracy_decimals=0,
 | 
			
		||||
                    filters=[
 | 
			
		||||
                        {
 | 
			
		||||
                            "timeout": {
 | 
			
		||||
                                "timeout": cv.TimePeriod(milliseconds=1000),
 | 
			
		||||
                                "value": "last",
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
 | 
			
		||||
                    ],
 | 
			
		||||
                    icon=ICON_MAP_MARKER_ACCOUNT,
 | 
			
		||||
                    accuracy_decimals=0,
 | 
			
		||||
                ),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -764,8 +764,7 @@ void Nextion::process_nextion_commands_() {
 | 
			
		||||
        variable_name = to_process.substr(0, index);
 | 
			
		||||
        ++index;
 | 
			
		||||
 | 
			
		||||
        // Get variable value without terminating NUL byte.  Length check above ensures substr len >= 0.
 | 
			
		||||
        text_value = to_process.substr(index, to_process_length - index - 1);
 | 
			
		||||
        text_value = to_process.substr(index);
 | 
			
		||||
 | 
			
		||||
        ESP_LOGN(TAG, "Text sensor: %s='%s'", variable_name.c_str(), text_value.c_str());
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,6 @@ from esphome.components.esp32.const import (
 | 
			
		||||
import esphome.config_validation as cv
 | 
			
		||||
from esphome.const import (
 | 
			
		||||
    CONF_ADVANCED,
 | 
			
		||||
    CONF_DISABLED,
 | 
			
		||||
    CONF_FRAMEWORK,
 | 
			
		||||
    CONF_ID,
 | 
			
		||||
    CONF_MODE,
 | 
			
		||||
@@ -103,7 +102,6 @@ def get_config_schema(config):
 | 
			
		||||
            cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True),
 | 
			
		||||
            cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean,
 | 
			
		||||
            cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True),
 | 
			
		||||
            cv.Optional(CONF_DISABLED, default=False): cv.boolean,
 | 
			
		||||
        }
 | 
			
		||||
    )(config)
 | 
			
		||||
 | 
			
		||||
@@ -114,8 +112,6 @@ FINAL_VALIDATE_SCHEMA = validate_psram_mode
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def to_code(config):
 | 
			
		||||
    if config[CONF_DISABLED]:
 | 
			
		||||
        return
 | 
			
		||||
    if CORE.using_arduino:
 | 
			
		||||
        cg.add_build_flag("-DBOARD_HAS_PSRAM")
 | 
			
		||||
        if config[CONF_MODE] == TYPE_OCTAL:
 | 
			
		||||
 
 | 
			
		||||
@@ -110,10 +110,10 @@ static uint8_t find_nearest_index(float value, const float *arr, int size) {
 | 
			
		||||
 * @param value The float value to convert.
 | 
			
		||||
 * @param bytes The byte array to store the converted value.
 | 
			
		||||
 */
 | 
			
		||||
static void float_to_bytes(float value, unsigned char *bytes) {
 | 
			
		||||
static void float_to_bytes(float value, uint8_t *bytes) {
 | 
			
		||||
  union {
 | 
			
		||||
    float float_value;
 | 
			
		||||
    unsigned char byte_array[4];
 | 
			
		||||
    uint8_t byte_array[4];
 | 
			
		||||
  } u;
 | 
			
		||||
 | 
			
		||||
  u.float_value = value;
 | 
			
		||||
@@ -128,7 +128,7 @@ static void float_to_bytes(float value, unsigned char *bytes) {
 | 
			
		||||
 * @param value The 32-bit unsigned integer to convert.
 | 
			
		||||
 * @param bytes The byte array to store the converted value.
 | 
			
		||||
 */
 | 
			
		||||
static void int_to_bytes(uint32_t value, unsigned char *bytes) {
 | 
			
		||||
static void int_to_bytes(uint32_t value, uint8_t *bytes) {
 | 
			
		||||
  bytes[0] = value & 0xFF;
 | 
			
		||||
  bytes[1] = (value >> 8) & 0xFF;
 | 
			
		||||
  bytes[2] = (value >> 16) & 0xFF;
 | 
			
		||||
 
 | 
			
		||||
@@ -53,14 +53,10 @@ void SenseAirComponent::update() {
 | 
			
		||||
 | 
			
		||||
  this->status_clear_warning();
 | 
			
		||||
  const uint8_t length = response[2];
 | 
			
		||||
  const uint16_t status = encode_uint16(response[3], response[4]);
 | 
			
		||||
  const uint16_t ppm = encode_uint16(response[length + 1], response[length + 2]);
 | 
			
		||||
  const uint16_t status = (uint16_t(response[3]) << 8) | response[4];
 | 
			
		||||
  const int16_t ppm = int16_t((response[length + 1] << 8) | response[length + 2]);
 | 
			
		||||
 | 
			
		||||
  ESP_LOGD(TAG, "SenseAir Received CO₂=%uppm Status=0x%02X", ppm, status);
 | 
			
		||||
  if (ppm == 0 && (status & SenseAirStatus::OUT_OF_RANGE_ERROR) != 0) {
 | 
			
		||||
    ESP_LOGD(TAG, "Discarding 0 ppm reading with out-of-range status.");
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  ESP_LOGD(TAG, "SenseAir Received CO₂=%dppm Status=0x%02X", ppm, status);
 | 
			
		||||
  if (this->co2_sensor_ != nullptr)
 | 
			
		||||
    this->co2_sensor_->publish_state(ppm);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,17 +8,6 @@
 | 
			
		||||
namespace esphome {
 | 
			
		||||
namespace senseair {
 | 
			
		||||
 | 
			
		||||
enum SenseAirStatus : uint8_t {
 | 
			
		||||
  FATAL_ERROR = 1 << 0,
 | 
			
		||||
  OFFSET_ERROR = 1 << 1,
 | 
			
		||||
  ALGORITHM_ERROR = 1 << 2,
 | 
			
		||||
  OUTPUT_ERROR = 1 << 3,
 | 
			
		||||
  SELF_DIAGNOSTIC_ERROR = 1 << 4,
 | 
			
		||||
  OUT_OF_RANGE_ERROR = 1 << 5,
 | 
			
		||||
  MEMORY_ERROR = 1 << 6,
 | 
			
		||||
  RESERVED = 1 << 7
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class SenseAirComponent : public PollingComponent, public uart::UARTDevice {
 | 
			
		||||
 public:
 | 
			
		||||
  void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; }
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ namespace esphome {
 | 
			
		||||
namespace tm1638 {
 | 
			
		||||
namespace TM1638Translation {
 | 
			
		||||
 | 
			
		||||
const unsigned char SEVEN_SEG[] PROGMEM = {
 | 
			
		||||
const uint8_t SEVEN_SEG[] PROGMEM = {
 | 
			
		||||
    0x00, /* (space) */
 | 
			
		||||
    0x86, /* ! */
 | 
			
		||||
    0x22, /* " */
 | 
			
		||||
 
 | 
			
		||||
@@ -813,7 +813,7 @@ std::string WebServer::cover_state_json_generator(WebServer *web_server, void *s
 | 
			
		||||
  return web_server->cover_json((cover::Cover *) (source), DETAIL_STATE);
 | 
			
		||||
}
 | 
			
		||||
std::string WebServer::cover_all_json_generator(WebServer *web_server, void *source) {
 | 
			
		||||
  return web_server->cover_json((cover::Cover *) (source), DETAIL_ALL);
 | 
			
		||||
  return web_server->cover_json((cover::Cover *) (source), DETAIL_STATE);
 | 
			
		||||
}
 | 
			
		||||
std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) {
 | 
			
		||||
  return json::build_json([this, obj, start_config](JsonObject root) {
 | 
			
		||||
 
 | 
			
		||||
@@ -375,16 +375,11 @@ async def to_code(config):
 | 
			
		||||
    var = cg.new_Pvariable(config[CONF_ID])
 | 
			
		||||
    cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
 | 
			
		||||
 | 
			
		||||
    # Track if any network uses Enterprise authentication
 | 
			
		||||
    has_eap = False
 | 
			
		||||
 | 
			
		||||
    def add_sta(ap, network):
 | 
			
		||||
        ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP))
 | 
			
		||||
        cg.add(var.add_sta(wifi_network(network, ap, ip_config)))
 | 
			
		||||
 | 
			
		||||
    for network in config.get(CONF_NETWORKS, []):
 | 
			
		||||
        if CONF_EAP in network:
 | 
			
		||||
            has_eap = True
 | 
			
		||||
        cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network)
 | 
			
		||||
 | 
			
		||||
    if CONF_AP in config:
 | 
			
		||||
@@ -401,10 +396,6 @@ async def to_code(config):
 | 
			
		||||
        add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False)
 | 
			
		||||
        add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False)
 | 
			
		||||
 | 
			
		||||
    # Disable Enterprise WiFi support if no EAP is configured
 | 
			
		||||
    if CORE.is_esp32 and CORE.using_esp_idf and not has_eap:
 | 
			
		||||
        add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False)
 | 
			
		||||
 | 
			
		||||
    cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT]))
 | 
			
		||||
    cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE]))
 | 
			
		||||
    cg.add(var.set_fast_connect(config[CONF_FAST_CONNECT]))
 | 
			
		||||
 
 | 
			
		||||
@@ -393,13 +393,10 @@ def icon(value):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def sub_device_id(value: str | None) -> core.ID | None:
 | 
			
		||||
def sub_device_id(value: str | None) -> core.ID:
 | 
			
		||||
    # Lazy import to avoid circular imports
 | 
			
		||||
    from esphome.core.config import Device
 | 
			
		||||
 | 
			
		||||
    if not value:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    return use_id(Device)(value)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ from enum import Enum
 | 
			
		||||
 | 
			
		||||
from esphome.enum import StrEnum
 | 
			
		||||
 | 
			
		||||
__version__ = "2025.8.0b3"
 | 
			
		||||
__version__ = "2025.8.0-dev"
 | 
			
		||||
 | 
			
		||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
 | 
			
		||||
VALID_SUBSTITUTIONS_CHARACTERS = (
 | 
			
		||||
 
 | 
			
		||||
@@ -803,10 +803,6 @@ class EsphomeCore:
 | 
			
		||||
            raise TypeError(
 | 
			
		||||
                f"Library {library} must be instance of Library, not {type(library)}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        if not library.name:
 | 
			
		||||
            raise ValueError(f"The library for {library.repository} must have a name")
 | 
			
		||||
 | 
			
		||||
        short_name = (
 | 
			
		||||
            library.name if "/" not in library.name else library.name.split("/")[-1]
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -154,7 +154,6 @@
 | 
			
		||||
#define USE_ESP32_BLE_CLIENT
 | 
			
		||||
#define USE_ESP32_BLE_DEVICE
 | 
			
		||||
#define USE_ESP32_BLE_SERVER
 | 
			
		||||
#define USE_ESP32_BLE_UUID
 | 
			
		||||
#define USE_ESP32_BLE_ADVERTISING
 | 
			
		||||
#define USE_I2C
 | 
			
		||||
#define USE_IMPROV
 | 
			
		||||
 
 | 
			
		||||
@@ -77,8 +77,8 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None:
 | 
			
		||||
    """
 | 
			
		||||
    # Get device info
 | 
			
		||||
    device_name: str | None = None
 | 
			
		||||
    device_id_obj: ID | None
 | 
			
		||||
    if device_id_obj := config.get(CONF_DEVICE_ID):
 | 
			
		||||
    if CONF_DEVICE_ID in config:
 | 
			
		||||
        device_id_obj: ID = config[CONF_DEVICE_ID]
 | 
			
		||||
        device: MockObj = await get_variable(device_id_obj)
 | 
			
		||||
        add(var.set_device(device))
 | 
			
		||||
        # Get device name for object ID calculation
 | 
			
		||||
@@ -199,8 +199,8 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
 | 
			
		||||
        # Get device name if entity is on a sub-device
 | 
			
		||||
        device_name = None
 | 
			
		||||
        device_id = ""  # Empty string for main device
 | 
			
		||||
        device_id_obj: ID | None
 | 
			
		||||
        if device_id_obj := config.get(CONF_DEVICE_ID):
 | 
			
		||||
        if CONF_DEVICE_ID in config:
 | 
			
		||||
            device_id_obj = config[CONF_DEVICE_ID]
 | 
			
		||||
            device_name = device_id_obj.id
 | 
			
		||||
            # Use the device ID string directly for uniqueness
 | 
			
		||||
            device_id = device_id_obj.id
 | 
			
		||||
 
 | 
			
		||||
@@ -82,13 +82,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
 | 
			
		||||
  item->set_name(name_cstr, !is_static_string);
 | 
			
		||||
  item->type = type;
 | 
			
		||||
  item->callback = std::move(func);
 | 
			
		||||
  // Initialize remove to false (though it should already be from constructor)
 | 
			
		||||
  // Not using mark_item_removed_ helper since we're setting to false, not true
 | 
			
		||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
 | 
			
		||||
  item->remove.store(false, std::memory_order_relaxed);
 | 
			
		||||
#else
 | 
			
		||||
  item->remove = false;
 | 
			
		||||
#endif
 | 
			
		||||
  item->is_retry = is_retry;
 | 
			
		||||
 | 
			
		||||
#ifndef ESPHOME_THREAD_SINGLE
 | 
			
		||||
@@ -404,31 +398,6 @@ void HOT Scheduler::call(uint32_t now) {
 | 
			
		||||
        this->pop_raw_();
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check if item is marked for removal
 | 
			
		||||
      // This handles two cases:
 | 
			
		||||
      // 1. Item was marked for removal after cleanup_() but before we got here
 | 
			
		||||
      // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_()
 | 
			
		||||
#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS
 | 
			
		||||
      // Multi-threaded platforms without atomics: must take lock to safely read remove flag
 | 
			
		||||
      {
 | 
			
		||||
        LockGuard guard{this->lock_};
 | 
			
		||||
        if (is_item_removed_(item.get())) {
 | 
			
		||||
          this->pop_raw_();
 | 
			
		||||
          this->to_remove_--;
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
#else
 | 
			
		||||
      // Single-threaded or multi-threaded with atomics: can check without lock
 | 
			
		||||
      if (is_item_removed_(item.get())) {
 | 
			
		||||
        LockGuard guard{this->lock_};
 | 
			
		||||
        this->pop_raw_();
 | 
			
		||||
        this->to_remove_--;
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
#ifdef ESPHOME_DEBUG_SCHEDULER
 | 
			
		||||
      const char *item_name = item->get_name();
 | 
			
		||||
      ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")",
 | 
			
		||||
@@ -549,7 +518,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
 | 
			
		||||
  if (type == SchedulerItem::TIMEOUT) {
 | 
			
		||||
    for (auto &item : this->defer_queue_) {
 | 
			
		||||
      if (this->matches_item_(item, component, name_cstr, type, match_retry)) {
 | 
			
		||||
        this->mark_item_removed_(item.get());
 | 
			
		||||
        item->remove = true;
 | 
			
		||||
        total_cancelled++;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@@ -559,7 +528,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
 | 
			
		||||
  // Cancel items in the main heap
 | 
			
		||||
  for (auto &item : this->items_) {
 | 
			
		||||
    if (this->matches_item_(item, component, name_cstr, type, match_retry)) {
 | 
			
		||||
      this->mark_item_removed_(item.get());
 | 
			
		||||
      item->remove = true;
 | 
			
		||||
      total_cancelled++;
 | 
			
		||||
      this->to_remove_++;  // Track removals for heap items
 | 
			
		||||
    }
 | 
			
		||||
@@ -568,7 +537,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
 | 
			
		||||
  // Cancel items in to_add_
 | 
			
		||||
  for (auto &item : this->to_add_) {
 | 
			
		||||
    if (this->matches_item_(item, component, name_cstr, type, match_retry)) {
 | 
			
		||||
      this->mark_item_removed_(item.get());
 | 
			
		||||
      item->remove = true;
 | 
			
		||||
      total_cancelled++;
 | 
			
		||||
      // Don't track removals for to_add_ items
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -97,42 +97,22 @@ class Scheduler {
 | 
			
		||||
 | 
			
		||||
    std::function<void()> callback;
 | 
			
		||||
 | 
			
		||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
 | 
			
		||||
    // Multi-threaded with atomics: use atomic for lock-free access
 | 
			
		||||
    // Place atomic<bool> separately since it can't be packed with bit fields
 | 
			
		||||
    std::atomic<bool> remove{false};
 | 
			
		||||
 | 
			
		||||
    // Bit-packed fields (3 bits used, 5 bits padding in 1 byte)
 | 
			
		||||
    enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1;
 | 
			
		||||
    bool name_is_dynamic : 1;  // True if name was dynamically allocated (needs delete[])
 | 
			
		||||
    bool is_retry : 1;         // True if this is a retry timeout
 | 
			
		||||
                               // 5 bits padding
 | 
			
		||||
#else
 | 
			
		||||
    // Single-threaded or multi-threaded without atomics: can pack all fields together
 | 
			
		||||
    // Bit-packed fields (4 bits used, 4 bits padding in 1 byte)
 | 
			
		||||
    // Bit-packed fields to minimize padding
 | 
			
		||||
    enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1;
 | 
			
		||||
    bool remove : 1;
 | 
			
		||||
    bool name_is_dynamic : 1;  // True if name was dynamically allocated (needs delete[])
 | 
			
		||||
    bool is_retry : 1;         // True if this is a retry timeout
 | 
			
		||||
                               // 4 bits padding
 | 
			
		||||
#endif
 | 
			
		||||
    // 4 bits padding
 | 
			
		||||
 | 
			
		||||
    // Constructor
 | 
			
		||||
    SchedulerItem()
 | 
			
		||||
        : component(nullptr),
 | 
			
		||||
          interval(0),
 | 
			
		||||
          next_execution_(0),
 | 
			
		||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
 | 
			
		||||
          // remove is initialized in the member declaration as std::atomic<bool>{false}
 | 
			
		||||
          type(TIMEOUT),
 | 
			
		||||
          name_is_dynamic(false),
 | 
			
		||||
          is_retry(false) {
 | 
			
		||||
#else
 | 
			
		||||
          type(TIMEOUT),
 | 
			
		||||
          remove(false),
 | 
			
		||||
          name_is_dynamic(false),
 | 
			
		||||
          is_retry(false) {
 | 
			
		||||
#endif
 | 
			
		||||
      name_.static_name = nullptr;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -239,37 +219,6 @@ class Scheduler {
 | 
			
		||||
    return item->remove || (item->component != nullptr && item->component->is_failed());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Helper to check if item is marked for removal (platform-specific)
 | 
			
		||||
  // Returns true if item should be skipped, handles platform-specific synchronization
 | 
			
		||||
  // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this
 | 
			
		||||
  // function.
 | 
			
		||||
  bool is_item_removed_(SchedulerItem *item) const {
 | 
			
		||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
 | 
			
		||||
    // Multi-threaded with atomics: use atomic load for lock-free access
 | 
			
		||||
    return item->remove.load(std::memory_order_acquire);
 | 
			
		||||
#else
 | 
			
		||||
    // Single-threaded (ESPHOME_THREAD_SINGLE) or
 | 
			
		||||
    // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct read
 | 
			
		||||
    // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock!
 | 
			
		||||
    return item->remove;
 | 
			
		||||
#endif
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Helper to mark item for removal (platform-specific)
 | 
			
		||||
  // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this
 | 
			
		||||
  // function.
 | 
			
		||||
  void mark_item_removed_(SchedulerItem *item) {
 | 
			
		||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
 | 
			
		||||
    // Multi-threaded with atomics: use atomic store
 | 
			
		||||
    item->remove.store(true, std::memory_order_release);
 | 
			
		||||
#else
 | 
			
		||||
    // Single-threaded (ESPHOME_THREAD_SINGLE) or
 | 
			
		||||
    // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct write
 | 
			
		||||
    // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock!
 | 
			
		||||
    item->remove = true;
 | 
			
		||||
#endif
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Template helper to check if any item in a container matches our criteria
 | 
			
		||||
  template<typename Container>
 | 
			
		||||
  bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr,
 | 
			
		||||
 
 | 
			
		||||
@@ -80,16 +80,13 @@ def replace_file_content(text, pattern, repl):
 | 
			
		||||
    return content_new, count
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
 | 
			
		||||
def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool:
 | 
			
		||||
    if old is None:
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    if old.src_version != new.src_version:
 | 
			
		||||
        return True
 | 
			
		||||
    if old.build_path != new.build_path:
 | 
			
		||||
        return True
 | 
			
		||||
    # Check if any components have been removed
 | 
			
		||||
    return bool(old.loaded_integrations - new.loaded_integrations)
 | 
			
		||||
    return old.build_path != new.build_path
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool:
 | 
			
		||||
@@ -103,7 +100,7 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_storage_json() -> None:
 | 
			
		||||
def update_storage_json():
 | 
			
		||||
    path = storage_path()
 | 
			
		||||
    old = StorageJSON.load(path)
 | 
			
		||||
    new = StorageJSON.from_esphome_core(CORE, old)
 | 
			
		||||
@@ -111,14 +108,7 @@ def update_storage_json() -> None:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if storage_should_clean(old, new):
 | 
			
		||||
        if old is not None and old.loaded_integrations - new.loaded_integrations:
 | 
			
		||||
            removed = old.loaded_integrations - new.loaded_integrations
 | 
			
		||||
            _LOGGER.info(
 | 
			
		||||
                "Components removed (%s), cleaning build files...",
 | 
			
		||||
                ", ".join(sorted(removed)),
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            _LOGGER.info("Core config or version changed, cleaning build files...")
 | 
			
		||||
        _LOGGER.info("Core config, version changed, cleaning build files...")
 | 
			
		||||
        clean_build()
 | 
			
		||||
    elif storage_should_update_cmake_cache(old, new):
 | 
			
		||||
        _LOGGER.info("Integrations changed, cleaning cmake cache...")
 | 
			
		||||
 
 | 
			
		||||
@@ -11,8 +11,8 @@ pyserial==3.5
 | 
			
		||||
platformio==6.1.18  # When updating platformio, also update /docker/Dockerfile
 | 
			
		||||
esptool==5.0.2
 | 
			
		||||
click==8.1.7
 | 
			
		||||
esphome-dashboard==20250814.0
 | 
			
		||||
aioesphomeapi==39.0.0
 | 
			
		||||
esphome-dashboard==20250514.0
 | 
			
		||||
aioesphomeapi==38.2.1
 | 
			
		||||
zeroconf==0.147.0
 | 
			
		||||
puremagic==1.30
 | 
			
		||||
ruamel.yaml==0.18.14 # dashboard_import
 | 
			
		||||
 
 | 
			
		||||
@@ -139,24 +139,9 @@ def _get_changed_files_github_actions() -> list[str] | None:
 | 
			
		||||
    if event_name == "pull_request":
 | 
			
		||||
        pr_number = _get_pr_number_from_github_env()
 | 
			
		||||
        if pr_number:
 | 
			
		||||
            # Try gh pr diff first (faster for small PRs)
 | 
			
		||||
            # Use GitHub CLI to get changed files directly
 | 
			
		||||
            cmd = ["gh", "pr", "diff", pr_number, "--name-only"]
 | 
			
		||||
            try:
 | 
			
		||||
                return _get_changed_files_from_command(cmd)
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                # If it fails due to the 300 file limit, use the API method
 | 
			
		||||
                if "maximum" in str(e) and "files" in str(e):
 | 
			
		||||
                    cmd = [
 | 
			
		||||
                        "gh",
 | 
			
		||||
                        "api",
 | 
			
		||||
                        f"repos/esphome/esphome/pulls/{pr_number}/files",
 | 
			
		||||
                        "--paginate",
 | 
			
		||||
                        "--jq",
 | 
			
		||||
                        ".[].filename",
 | 
			
		||||
                    ]
 | 
			
		||||
                    return _get_changed_files_from_command(cmd)
 | 
			
		||||
                # Re-raise for other errors
 | 
			
		||||
                raise
 | 
			
		||||
            return _get_changed_files_from_command(cmd)
 | 
			
		||||
 | 
			
		||||
    # For pushes (including squash-and-merge)
 | 
			
		||||
    elif event_name == "push":
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ uart:
 | 
			
		||||
ld2450:
 | 
			
		||||
  - id: ld2450_radar
 | 
			
		||||
    uart_id: ld2450_uart
 | 
			
		||||
    throttle: 1000ms
 | 
			
		||||
 | 
			
		||||
button:
 | 
			
		||||
  - platform: ld2450
 | 
			
		||||
 
 | 
			
		||||
@@ -55,12 +55,6 @@ sensor:
 | 
			
		||||
    lambda: return 4.0;
 | 
			
		||||
    update_interval: 0.1s
 | 
			
		||||
 | 
			
		||||
  - platform: template
 | 
			
		||||
    name: Living Room Sensor
 | 
			
		||||
    device_id: ""
 | 
			
		||||
    lambda: return 5.0;
 | 
			
		||||
    update_interval: 0.1s
 | 
			
		||||
 | 
			
		||||
# Switches with the same name on different devices to test device_id lookup
 | 
			
		||||
switch:
 | 
			
		||||
  # Switch with no device_id (defaults to 0)
 | 
			
		||||
@@ -102,23 +96,3 @@ switch:
 | 
			
		||||
      - logger.log: "Turning on Test Switch on Motion Detector"
 | 
			
		||||
    turn_off_action:
 | 
			
		||||
      - logger.log: "Turning off Test Switch on Motion Detector"
 | 
			
		||||
 | 
			
		||||
  - platform: template
 | 
			
		||||
    name: Living Room Blank Switch
 | 
			
		||||
    device_id: ""
 | 
			
		||||
    id: test_switch_blank_living_room
 | 
			
		||||
    optimistic: true
 | 
			
		||||
    turn_on_action:
 | 
			
		||||
      - logger.log: "Turning on Living Room Blank Switch"
 | 
			
		||||
    turn_off_action:
 | 
			
		||||
      - logger.log: "Turning off Living Room Blank Switch"
 | 
			
		||||
 | 
			
		||||
  - platform: template
 | 
			
		||||
    name: Living Room None Switch
 | 
			
		||||
    device_id:
 | 
			
		||||
    id: test_switch_none_living_room
 | 
			
		||||
    optimistic: true
 | 
			
		||||
    turn_on_action:
 | 
			
		||||
      - logger.log: "Turning on Living Room None Switch"
 | 
			
		||||
    turn_off_action:
 | 
			
		||||
      - logger.log: "Turning off Living Room None Switch"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,139 +0,0 @@
 | 
			
		||||
esphome:
 | 
			
		||||
  name: scheduler-removed-item-race
 | 
			
		||||
 | 
			
		||||
host:
 | 
			
		||||
 | 
			
		||||
api:
 | 
			
		||||
  services:
 | 
			
		||||
    - service: run_test
 | 
			
		||||
      then:
 | 
			
		||||
        - script.execute: run_test_script
 | 
			
		||||
 | 
			
		||||
logger:
 | 
			
		||||
  level: DEBUG
 | 
			
		||||
 | 
			
		||||
globals:
 | 
			
		||||
  - id: test_passed
 | 
			
		||||
    type: bool
 | 
			
		||||
    initial_value: 'true'
 | 
			
		||||
  - id: removed_item_executed
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
  - id: normal_item_executed
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
 | 
			
		||||
sensor:
 | 
			
		||||
  - platform: template
 | 
			
		||||
    id: test_sensor
 | 
			
		||||
    name: "Test Sensor"
 | 
			
		||||
    update_interval: never
 | 
			
		||||
    lambda: return 0.0;
 | 
			
		||||
 | 
			
		||||
script:
 | 
			
		||||
  - id: run_test_script
 | 
			
		||||
    then:
 | 
			
		||||
      - logger.log: "=== Starting Removed Item Race Test ==="
 | 
			
		||||
 | 
			
		||||
      # This test creates a scenario where:
 | 
			
		||||
      # 1. First item in heap is NOT cancelled (cleanup stops immediately)
 | 
			
		||||
      # 2. Items behind it ARE cancelled (remain in heap after cleanup)
 | 
			
		||||
      # 3. All items execute at the same time, including cancelled ones
 | 
			
		||||
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          // The key to hitting the race:
 | 
			
		||||
          // 1. Add items in a specific order to control heap structure
 | 
			
		||||
          // 2. Cancel ONLY items that won't be at the front
 | 
			
		||||
          // 3. Ensure the first item stays non-cancelled so cleanup_() stops immediately
 | 
			
		||||
 | 
			
		||||
          // Schedule all items to execute at the SAME time (1ms from now)
 | 
			
		||||
          // Using 1ms instead of 0 to avoid defer queue on multi-core platforms
 | 
			
		||||
          // This ensures they'll all be ready together and go through the heap
 | 
			
		||||
          const uint32_t exec_time = 1;
 | 
			
		||||
 | 
			
		||||
          // CRITICAL: Add a non-cancellable item FIRST
 | 
			
		||||
          // This will be at the front of the heap and block cleanup_()
 | 
			
		||||
          App.scheduler.set_timeout(id(test_sensor), "blocker", exec_time, []() {
 | 
			
		||||
            ESP_LOGD("test", "Blocker timeout executed (expected) - was at front of heap");
 | 
			
		||||
            id(normal_item_executed)++;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          // Now add items that we WILL cancel
 | 
			
		||||
          // These will be behind the blocker in the heap
 | 
			
		||||
          App.scheduler.set_timeout(id(test_sensor), "cancel_1", exec_time, []() {
 | 
			
		||||
            ESP_LOGE("test", "RACE: Cancelled timeout 1 executed after being cancelled!");
 | 
			
		||||
            id(removed_item_executed)++;
 | 
			
		||||
            id(test_passed) = false;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          App.scheduler.set_timeout(id(test_sensor), "cancel_2", exec_time, []() {
 | 
			
		||||
            ESP_LOGE("test", "RACE: Cancelled timeout 2 executed after being cancelled!");
 | 
			
		||||
            id(removed_item_executed)++;
 | 
			
		||||
            id(test_passed) = false;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          App.scheduler.set_timeout(id(test_sensor), "cancel_3", exec_time, []() {
 | 
			
		||||
            ESP_LOGE("test", "RACE: Cancelled timeout 3 executed after being cancelled!");
 | 
			
		||||
            id(removed_item_executed)++;
 | 
			
		||||
            id(test_passed) = false;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          // Add some more normal items
 | 
			
		||||
          App.scheduler.set_timeout(id(test_sensor), "normal_1", exec_time, []() {
 | 
			
		||||
            ESP_LOGD("test", "Normal timeout 1 executed (expected)");
 | 
			
		||||
            id(normal_item_executed)++;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          App.scheduler.set_timeout(id(test_sensor), "normal_2", exec_time, []() {
 | 
			
		||||
            ESP_LOGD("test", "Normal timeout 2 executed (expected)");
 | 
			
		||||
            id(normal_item_executed)++;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          App.scheduler.set_timeout(id(test_sensor), "normal_3", exec_time, []() {
 | 
			
		||||
            ESP_LOGD("test", "Normal timeout 3 executed (expected)");
 | 
			
		||||
            id(normal_item_executed)++;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          // Force items into the heap before cancelling
 | 
			
		||||
          App.scheduler.process_to_add();
 | 
			
		||||
 | 
			
		||||
          // NOW cancel the items - they're behind "blocker" in the heap
 | 
			
		||||
          // When cleanup_() runs, it will see "blocker" (not removed) at the front
 | 
			
		||||
          // and stop immediately, leaving cancel_1, cancel_2, cancel_3 in the heap
 | 
			
		||||
          bool c1 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_1");
 | 
			
		||||
          bool c2 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_2");
 | 
			
		||||
          bool c3 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_3");
 | 
			
		||||
 | 
			
		||||
          ESP_LOGD("test", "Cancelled items (behind blocker): %s, %s, %s",
 | 
			
		||||
                   c1 ? "true" : "false",
 | 
			
		||||
                   c2 ? "true" : "false",
 | 
			
		||||
                   c3 ? "true" : "false");
 | 
			
		||||
 | 
			
		||||
          // The heap now has:
 | 
			
		||||
          // - "blocker" at front (not cancelled)
 | 
			
		||||
          // - cancelled items behind it (marked remove=true but still in heap)
 | 
			
		||||
          // - When all execute at once, cleanup_() stops at "blocker"
 | 
			
		||||
          // - The loop then executes ALL ready items including cancelled ones
 | 
			
		||||
 | 
			
		||||
          ESP_LOGD("test", "Setup complete. Blocker at front prevents cleanup of cancelled items behind it");
 | 
			
		||||
 | 
			
		||||
      # Wait for all timeouts to execute (or not)
 | 
			
		||||
      - delay: 20ms
 | 
			
		||||
 | 
			
		||||
      # Check results
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          ESP_LOGI("test", "=== Test Results ===");
 | 
			
		||||
          ESP_LOGI("test", "Normal items executed: %d (expected 4)", id(normal_item_executed));
 | 
			
		||||
          ESP_LOGI("test", "Removed items executed: %d (expected 0)", id(removed_item_executed));
 | 
			
		||||
 | 
			
		||||
          if (id(removed_item_executed) > 0) {
 | 
			
		||||
            ESP_LOGE("test", "TEST FAILED: %d cancelled items were executed!", id(removed_item_executed));
 | 
			
		||||
            id(test_passed) = false;
 | 
			
		||||
          } else if (id(normal_item_executed) != 4) {
 | 
			
		||||
            ESP_LOGE("test", "TEST FAILED: Expected 4 normal items, got %d", id(normal_item_executed));
 | 
			
		||||
            id(test_passed) = false;
 | 
			
		||||
          } else {
 | 
			
		||||
            ESP_LOGI("test", "TEST PASSED: No cancelled items were executed");
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          ESP_LOGI("test", "=== Test Complete ===");
 | 
			
		||||
@@ -132,7 +132,6 @@ async def test_areas_and_devices(
 | 
			
		||||
            "Temperature Sensor Reading": temp_sensor.device_id,
 | 
			
		||||
            "Motion Detector Status": motion_detector.device_id,
 | 
			
		||||
            "Smart Switch Power": smart_switch.device_id,
 | 
			
		||||
            "Living Room Sensor": 0,  # Main device
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for entity in sensor_entities:
 | 
			
		||||
@@ -161,18 +160,6 @@ async def test_areas_and_devices(
 | 
			
		||||
            "Should have a switch with device_id 0 (main device)"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Verify extra switches with blank and none device_id are correctly available
 | 
			
		||||
        extra_switches = [
 | 
			
		||||
            e for e in switch_entities if e.name.startswith("Living Room")
 | 
			
		||||
        ]
 | 
			
		||||
        assert len(extra_switches) == 2, (
 | 
			
		||||
            f"Expected 2 extra switches for Living Room, got {len(extra_switches)}"
 | 
			
		||||
        )
 | 
			
		||||
        extra_switch_device_ids = [e.device_id for e in extra_switches]
 | 
			
		||||
        assert all(d == 0 for d in extra_switch_device_ids), (
 | 
			
		||||
            "All extra switches should have device_id 0 (main device)"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for initial states to be received for all switches
 | 
			
		||||
        await asyncio.wait_for(initial_states_future, timeout=2.0)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,102 +0,0 @@
 | 
			
		||||
"""Test for scheduler race condition where removed items still execute."""
 | 
			
		||||
 | 
			
		||||
import asyncio
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from .types import APIClientConnectedFactory, RunCompiledFunction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.asyncio
 | 
			
		||||
async def test_scheduler_removed_item_race(
 | 
			
		||||
    yaml_config: str,
 | 
			
		||||
    run_compiled: RunCompiledFunction,
 | 
			
		||||
    api_client_connected: APIClientConnectedFactory,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that items marked for removal don't execute.
 | 
			
		||||
 | 
			
		||||
    This test verifies the fix for a race condition where:
 | 
			
		||||
    1. cleanup_() only removes items from the front of the heap
 | 
			
		||||
    2. Items in the middle of the heap marked for removal still execute
 | 
			
		||||
    3. This causes cancelled timeouts to run when they shouldn't
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    loop = asyncio.get_running_loop()
 | 
			
		||||
    test_complete_future: asyncio.Future[bool] = loop.create_future()
 | 
			
		||||
 | 
			
		||||
    # Track test results
 | 
			
		||||
    test_passed = False
 | 
			
		||||
    removed_executed = 0
 | 
			
		||||
    normal_executed = 0
 | 
			
		||||
 | 
			
		||||
    # Patterns to match
 | 
			
		||||
    race_pattern = re.compile(r"RACE: .* executed after being cancelled!")
 | 
			
		||||
    passed_pattern = re.compile(r"TEST PASSED")
 | 
			
		||||
    failed_pattern = re.compile(r"TEST FAILED")
 | 
			
		||||
    complete_pattern = re.compile(r"=== Test Complete ===")
 | 
			
		||||
    normal_count_pattern = re.compile(r"Normal items executed: (\d+)")
 | 
			
		||||
    removed_count_pattern = re.compile(r"Removed items executed: (\d+)")
 | 
			
		||||
 | 
			
		||||
    def check_output(line: str) -> None:
 | 
			
		||||
        """Check log output for test results."""
 | 
			
		||||
        nonlocal test_passed, removed_executed, normal_executed
 | 
			
		||||
 | 
			
		||||
        if race_pattern.search(line):
 | 
			
		||||
            # Race condition detected - a cancelled item executed
 | 
			
		||||
            test_passed = False
 | 
			
		||||
 | 
			
		||||
        if passed_pattern.search(line):
 | 
			
		||||
            test_passed = True
 | 
			
		||||
        elif failed_pattern.search(line):
 | 
			
		||||
            test_passed = False
 | 
			
		||||
 | 
			
		||||
        normal_match = normal_count_pattern.search(line)
 | 
			
		||||
        if normal_match:
 | 
			
		||||
            normal_executed = int(normal_match.group(1))
 | 
			
		||||
 | 
			
		||||
        removed_match = removed_count_pattern.search(line)
 | 
			
		||||
        if removed_match:
 | 
			
		||||
            removed_executed = int(removed_match.group(1))
 | 
			
		||||
 | 
			
		||||
        if not test_complete_future.done() and complete_pattern.search(line):
 | 
			
		||||
            test_complete_future.set_result(True)
 | 
			
		||||
 | 
			
		||||
    async with (
 | 
			
		||||
        run_compiled(yaml_config, line_callback=check_output),
 | 
			
		||||
        api_client_connected() as client,
 | 
			
		||||
    ):
 | 
			
		||||
        # Verify we can connect
 | 
			
		||||
        device_info = await client.device_info()
 | 
			
		||||
        assert device_info is not None
 | 
			
		||||
        assert device_info.name == "scheduler-removed-item-race"
 | 
			
		||||
 | 
			
		||||
        # List services
 | 
			
		||||
        _, services = await asyncio.wait_for(
 | 
			
		||||
            client.list_entities_services(), timeout=5.0
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Find run_test service
 | 
			
		||||
        run_test_service = next((s for s in services if s.name == "run_test"), None)
 | 
			
		||||
        assert run_test_service is not None, "run_test service not found"
 | 
			
		||||
 | 
			
		||||
        # Execute the test
 | 
			
		||||
        client.execute_service(run_test_service, {})
 | 
			
		||||
 | 
			
		||||
        # Wait for test completion
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(test_complete_future, timeout=5.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail("Test did not complete within timeout")
 | 
			
		||||
 | 
			
		||||
        # Verify results
 | 
			
		||||
        assert test_passed, (
 | 
			
		||||
            f"Test failed! Removed items executed: {removed_executed}, "
 | 
			
		||||
            f"Normal items executed: {normal_executed}"
 | 
			
		||||
        )
 | 
			
		||||
        assert removed_executed == 0, (
 | 
			
		||||
            f"Cancelled items should not execute, but {removed_executed} did"
 | 
			
		||||
        )
 | 
			
		||||
        assert normal_executed == 4, (
 | 
			
		||||
            f"Expected 4 normal items to execute, got {normal_executed}"
 | 
			
		||||
        )
 | 
			
		||||
@@ -183,61 +183,6 @@ def test_get_changed_files_github_actions_pull_request(
 | 
			
		||||
        assert result == expected_files
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_get_changed_files_github_actions_pull_request_large_pr(
 | 
			
		||||
    monkeypatch: MonkeyPatch,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test _get_changed_files_github_actions fallback for PRs with >300 files."""
 | 
			
		||||
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
 | 
			
		||||
 | 
			
		||||
    expected_files = ["file1.py", "file2.cpp"]
 | 
			
		||||
 | 
			
		||||
    with (
 | 
			
		||||
        patch("helpers._get_pr_number_from_github_env", return_value="10214"),
 | 
			
		||||
        patch("helpers._get_changed_files_from_command") as mock_get,
 | 
			
		||||
    ):
 | 
			
		||||
        # First call fails with too many files error, second succeeds with API method
 | 
			
		||||
        mock_get.side_effect = [
 | 
			
		||||
            Exception("Sorry, the diff exceeded the maximum number of files (300)"),
 | 
			
		||||
            expected_files,
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        result = _get_changed_files_github_actions()
 | 
			
		||||
 | 
			
		||||
        assert mock_get.call_count == 2
 | 
			
		||||
        mock_get.assert_any_call(["gh", "pr", "diff", "10214", "--name-only"])
 | 
			
		||||
        mock_get.assert_any_call(
 | 
			
		||||
            [
 | 
			
		||||
                "gh",
 | 
			
		||||
                "api",
 | 
			
		||||
                "repos/esphome/esphome/pulls/10214/files",
 | 
			
		||||
                "--paginate",
 | 
			
		||||
                "--jq",
 | 
			
		||||
                ".[].filename",
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        assert result == expected_files
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_get_changed_files_github_actions_pull_request_other_error(
 | 
			
		||||
    monkeypatch: MonkeyPatch,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test _get_changed_files_github_actions re-raises non-file-limit errors."""
 | 
			
		||||
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
 | 
			
		||||
 | 
			
		||||
    with (
 | 
			
		||||
        patch("helpers._get_pr_number_from_github_env", return_value="1234"),
 | 
			
		||||
        patch("helpers._get_changed_files_from_command") as mock_get,
 | 
			
		||||
    ):
 | 
			
		||||
        # Error that is not about file limit
 | 
			
		||||
        mock_get.side_effect = Exception("Command failed: authentication required")
 | 
			
		||||
 | 
			
		||||
        with pytest.raises(Exception, match="authentication required"):
 | 
			
		||||
            _get_changed_files_github_actions()
 | 
			
		||||
 | 
			
		||||
        # Should only be called once (no retry with API)
 | 
			
		||||
        mock_get.assert_called_once_with(["gh", "pr", "diff", "1234", "--name-only"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_get_changed_files_github_actions_pull_request_no_pr_number(
 | 
			
		||||
    monkeypatch: MonkeyPatch,
 | 
			
		||||
) -> None:
 | 
			
		||||
 
 | 
			
		||||
@@ -689,19 +689,3 @@ def test_entity_duplicate_validator_internal_entities() -> None:
 | 
			
		||||
        Invalid, match=r"Duplicate sensor entity with name 'Temperature' found"
 | 
			
		||||
    ):
 | 
			
		||||
        validator(config4)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_empty_or_null_device_id_on_entity() -> None:
 | 
			
		||||
    """Test that empty or null device IDs are handled correctly."""
 | 
			
		||||
    # Create validator for sensor platform
 | 
			
		||||
    validator = entity_duplicate_validator("sensor")
 | 
			
		||||
 | 
			
		||||
    # Entity with empty device_id should pass
 | 
			
		||||
    config1 = {CONF_NAME: "Battery", CONF_DEVICE_ID: ""}
 | 
			
		||||
    validated1 = validator(config1)
 | 
			
		||||
    assert validated1 == config1
 | 
			
		||||
 | 
			
		||||
    # Entity with None device_id should pass
 | 
			
		||||
    config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None}
 | 
			
		||||
    validated2 = validator(config2)
 | 
			
		||||
    assert validated2 == config2
 | 
			
		||||
 
 | 
			
		||||
@@ -1,220 +0,0 @@
 | 
			
		||||
"""Test writer module functionality."""
 | 
			
		||||
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from typing import Any
 | 
			
		||||
from unittest.mock import MagicMock, patch
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from esphome.storage_json import StorageJSON
 | 
			
		||||
from esphome.writer import storage_should_clean, update_storage_json
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
def create_storage() -> Callable[..., StorageJSON]:
 | 
			
		||||
    """Factory fixture to create StorageJSON instances."""
 | 
			
		||||
 | 
			
		||||
    def _create(
 | 
			
		||||
        loaded_integrations: list[str] | None = None, **kwargs: Any
 | 
			
		||||
    ) -> StorageJSON:
 | 
			
		||||
        return StorageJSON(
 | 
			
		||||
            storage_version=kwargs.get("storage_version", 1),
 | 
			
		||||
            name=kwargs.get("name", "test"),
 | 
			
		||||
            friendly_name=kwargs.get("friendly_name", "Test Device"),
 | 
			
		||||
            comment=kwargs.get("comment"),
 | 
			
		||||
            esphome_version=kwargs.get("esphome_version", "2025.1.0"),
 | 
			
		||||
            src_version=kwargs.get("src_version", 1),
 | 
			
		||||
            address=kwargs.get("address", "test.local"),
 | 
			
		||||
            web_port=kwargs.get("web_port", 80),
 | 
			
		||||
            target_platform=kwargs.get("target_platform", "ESP32"),
 | 
			
		||||
            build_path=kwargs.get("build_path", "/build"),
 | 
			
		||||
            firmware_bin_path=kwargs.get("firmware_bin_path", "/firmware.bin"),
 | 
			
		||||
            loaded_integrations=set(loaded_integrations or []),
 | 
			
		||||
            loaded_platforms=kwargs.get("loaded_platforms", set()),
 | 
			
		||||
            no_mdns=kwargs.get("no_mdns", False),
 | 
			
		||||
            framework=kwargs.get("framework", "arduino"),
 | 
			
		||||
            core_platform=kwargs.get("core_platform", "esp32"),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    return _create
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_clean_when_old_is_none(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is triggered when old storage is None."""
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi"])
 | 
			
		||||
    assert storage_should_clean(None, new) is True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_clean_when_src_version_changes(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is triggered when src_version changes."""
 | 
			
		||||
    old = create_storage(loaded_integrations=["api", "wifi"], src_version=1)
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi"], src_version=2)
 | 
			
		||||
    assert storage_should_clean(old, new) is True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_clean_when_build_path_changes(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is triggered when build_path changes."""
 | 
			
		||||
    old = create_storage(loaded_integrations=["api", "wifi"], build_path="/build1")
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi"], build_path="/build2")
 | 
			
		||||
    assert storage_should_clean(old, new) is True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_clean_when_component_removed(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is triggered when a component is removed."""
 | 
			
		||||
    old = create_storage(
 | 
			
		||||
        loaded_integrations=["api", "wifi", "bluetooth_proxy", "esp32_ble_tracker"]
 | 
			
		||||
    )
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi", "esp32_ble_tracker"])
 | 
			
		||||
    assert storage_should_clean(old, new) is True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_clean_when_multiple_components_removed(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is triggered when multiple components are removed."""
 | 
			
		||||
    old = create_storage(
 | 
			
		||||
        loaded_integrations=["api", "wifi", "ota", "web_server", "logger"]
 | 
			
		||||
    )
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi", "logger"])
 | 
			
		||||
    assert storage_should_clean(old, new) is True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_not_clean_when_nothing_changes(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is not triggered when nothing changes."""
 | 
			
		||||
    old = create_storage(loaded_integrations=["api", "wifi", "logger"])
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi", "logger"])
 | 
			
		||||
    assert storage_should_clean(old, new) is False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_not_clean_when_component_added(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is not triggered when a component is only added."""
 | 
			
		||||
    old = create_storage(loaded_integrations=["api", "wifi"])
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi", "ota"])
 | 
			
		||||
    assert storage_should_clean(old, new) is False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_should_not_clean_when_other_fields_change(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that clean is not triggered when non-relevant fields change."""
 | 
			
		||||
    old = create_storage(
 | 
			
		||||
        loaded_integrations=["api", "wifi"],
 | 
			
		||||
        friendly_name="Old Name",
 | 
			
		||||
        esphome_version="2024.12.0",
 | 
			
		||||
    )
 | 
			
		||||
    new = create_storage(
 | 
			
		||||
        loaded_integrations=["api", "wifi"],
 | 
			
		||||
        friendly_name="New Name",
 | 
			
		||||
        esphome_version="2025.1.0",
 | 
			
		||||
    )
 | 
			
		||||
    assert storage_should_clean(old, new) is False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_edge_case_empty_integrations(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test edge case when old has integrations but new has none."""
 | 
			
		||||
    old = create_storage(loaded_integrations=["api", "wifi"])
 | 
			
		||||
    new = create_storage(loaded_integrations=[])
 | 
			
		||||
    assert storage_should_clean(old, new) is True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_storage_edge_case_from_empty_integrations(
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test edge case when old has no integrations but new has some."""
 | 
			
		||||
    old = create_storage(loaded_integrations=[])
 | 
			
		||||
    new = create_storage(loaded_integrations=["api", "wifi"])
 | 
			
		||||
    assert storage_should_clean(old, new) is False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@patch("esphome.writer.clean_build")
 | 
			
		||||
@patch("esphome.writer.StorageJSON")
 | 
			
		||||
@patch("esphome.writer.storage_path")
 | 
			
		||||
@patch("esphome.writer.CORE")
 | 
			
		||||
def test_update_storage_json_logging_when_old_is_none(
 | 
			
		||||
    mock_core: MagicMock,
 | 
			
		||||
    mock_storage_path: MagicMock,
 | 
			
		||||
    mock_storage_json_class: MagicMock,
 | 
			
		||||
    mock_clean_build: MagicMock,
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
    caplog: pytest.LogCaptureFixture,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that update_storage_json doesn't crash when old storage is None.
 | 
			
		||||
 | 
			
		||||
    This is a regression test for the AttributeError that occurred when
 | 
			
		||||
    old was None and we tried to access old.loaded_integrations.
 | 
			
		||||
    """
 | 
			
		||||
    # Setup mocks
 | 
			
		||||
    mock_storage_path.return_value = "/test/path"
 | 
			
		||||
    mock_storage_json_class.load.return_value = None  # Old storage is None
 | 
			
		||||
 | 
			
		||||
    new_storage = create_storage(loaded_integrations=["api", "wifi"])
 | 
			
		||||
    new_storage.save = MagicMock()  # Mock the save method
 | 
			
		||||
    mock_storage_json_class.from_esphome_core.return_value = new_storage
 | 
			
		||||
 | 
			
		||||
    # Call the function - should not raise AttributeError
 | 
			
		||||
    with caplog.at_level("INFO"):
 | 
			
		||||
        update_storage_json()
 | 
			
		||||
 | 
			
		||||
    # Verify clean_build was called
 | 
			
		||||
    mock_clean_build.assert_called_once()
 | 
			
		||||
 | 
			
		||||
    # Verify the correct log message was used (not the component removal message)
 | 
			
		||||
    assert "Core config or version changed, cleaning build files..." in caplog.text
 | 
			
		||||
    assert "Components removed" not in caplog.text
 | 
			
		||||
 | 
			
		||||
    # Verify save was called
 | 
			
		||||
    new_storage.save.assert_called_once_with("/test/path")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@patch("esphome.writer.clean_build")
 | 
			
		||||
@patch("esphome.writer.StorageJSON")
 | 
			
		||||
@patch("esphome.writer.storage_path")
 | 
			
		||||
@patch("esphome.writer.CORE")
 | 
			
		||||
def test_update_storage_json_logging_components_removed(
 | 
			
		||||
    mock_core: MagicMock,
 | 
			
		||||
    mock_storage_path: MagicMock,
 | 
			
		||||
    mock_storage_json_class: MagicMock,
 | 
			
		||||
    mock_clean_build: MagicMock,
 | 
			
		||||
    create_storage: Callable[..., StorageJSON],
 | 
			
		||||
    caplog: pytest.LogCaptureFixture,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that update_storage_json logs removed components correctly."""
 | 
			
		||||
    # Setup mocks
 | 
			
		||||
    mock_storage_path.return_value = "/test/path"
 | 
			
		||||
 | 
			
		||||
    old_storage = create_storage(loaded_integrations=["api", "wifi", "bluetooth_proxy"])
 | 
			
		||||
    new_storage = create_storage(loaded_integrations=["api", "wifi"])
 | 
			
		||||
    new_storage.save = MagicMock()  # Mock the save method
 | 
			
		||||
 | 
			
		||||
    mock_storage_json_class.load.return_value = old_storage
 | 
			
		||||
    mock_storage_json_class.from_esphome_core.return_value = new_storage
 | 
			
		||||
 | 
			
		||||
    # Call the function
 | 
			
		||||
    with caplog.at_level("INFO"):
 | 
			
		||||
        update_storage_json()
 | 
			
		||||
 | 
			
		||||
    # Verify clean_build was called
 | 
			
		||||
    mock_clean_build.assert_called_once()
 | 
			
		||||
 | 
			
		||||
    # Verify the correct log message was used with component names
 | 
			
		||||
    assert (
 | 
			
		||||
        "Components removed (bluetooth_proxy), cleaning build files..." in caplog.text
 | 
			
		||||
    )
 | 
			
		||||
    assert "Core config or version changed" not in caplog.text
 | 
			
		||||
 | 
			
		||||
    # Verify save was called
 | 
			
		||||
    new_storage.save.assert_called_once_with("/test/path")
 | 
			
		||||
		Reference in New Issue
	
	Block a user