1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-01 19:02:18 +01:00

Merge remote-tracking branch 'upstream/dev' into zwave_proxy

This commit is contained in:
Keith Burzinski
2025-08-26 01:56:13 -05:00
68 changed files with 1067 additions and 145 deletions

View File

@@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro
## 2. Core Technologies & Stack ## 2. Core Technologies & Stack
* **Languages:** Python (>=3.10), C++ (gnu++20) * **Languages:** Python (>=3.11), C++ (gnu++20)
* **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF.
* **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative.
* **Configuration:** YAML. * **Configuration:** YAML.
@@ -38,7 +38,7 @@ This document provides essential context for AI models interacting with this pro
5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates. 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates.
* **Platform Support:** * **Platform Support:**
1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (S2, S3, C3, etc.) and both IDF and Arduino frameworks. 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3).
2. **ESP8266** (`components/esp8266/`): Espressif ESP8266. Arduino framework only, with memory constraints. 2. **ESP8266** (`components/esp8266/`): Espressif ESP8266. Arduino framework only, with memory constraints.
3. **RP2040** (`components/rp2040/`): Raspberry Pi Pico/RP2040. Arduino framework with PIO (Programmable I/O) support. 3. **RP2040** (`components/rp2040/`): Raspberry Pi Pico/RP2040. Arduino framework with PIO (Programmable I/O) support.
4. **LibreTiny** (`components/libretiny/`): Realtek and Beken chips. Supports multiple chip families and auto-generated components. 4. **LibreTiny** (`components/libretiny/`): Realtek and Beken chips. Supports multiple chip families and auto-generated components.
@@ -60,7 +60,7 @@ This document provides essential context for AI models interacting with this pro
├── __init__.py # Component configuration schema and code generation ├── __init__.py # Component configuration schema and code generation
├── [component].h # C++ header file (if needed) ├── [component].h # C++ header file (if needed)
├── [component].cpp # C++ implementation (if needed) ├── [component].cpp # C++ implementation (if needed)
└── [platform]/ # Platform-specific implementations └── [platform]/ # Platform-specific implementations
├── __init__.py # Platform-specific configuration ├── __init__.py # Platform-specific configuration
├── [platform].h # Platform C++ header ├── [platform].h # Platform C++ header
└── [platform].cpp # Platform C++ implementation └── [platform].cpp # Platform C++ implementation
@@ -150,7 +150,8 @@ This document provides essential context for AI models interacting with this pro
* **Configuration Validation:** * **Configuration Validation:**
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `cv.only_with_arduino`. * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`, `cv.only_with_esp_idf`.
* **Schema Extensions:** * **Schema Extensions:**
```python ```python
CONFIG_SCHEMA = cv.Schema({ ... }) CONFIG_SCHEMA = cv.Schema({ ... })

View File

@@ -1 +1 @@
0440e35cf89a49e8a35fd3690ed453a72b7b6f61b9d346ced6140e1c0d39dff6 4368db58e8f884aff245996b1e8b644cc0796c0bb2fa706d5740d40b823d3ac9

View File

@@ -11,7 +11,7 @@ ci:
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.12.9 rev: v0.12.10
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff

View File

@@ -327,7 +327,7 @@ void Climate::add_on_control_callback(std::function<void(ClimateCall &)> &&callb
static const uint32_t RESTORE_STATE_VERSION = 0x848EA6ADUL; static const uint32_t RESTORE_STATE_VERSION = 0x848EA6ADUL;
optional<ClimateDeviceRestoreState> Climate::restore_state_() { optional<ClimateDeviceRestoreState> Climate::restore_state_() {
this->rtc_ = global_preferences->make_preference<ClimateDeviceRestoreState>(this->get_object_id_hash() ^ this->rtc_ = global_preferences->make_preference<ClimateDeviceRestoreState>(this->get_preference_hash() ^
RESTORE_STATE_VERSION); RESTORE_STATE_VERSION);
ClimateDeviceRestoreState recovered{}; ClimateDeviceRestoreState recovered{};
if (!this->rtc_.load(&recovered)) if (!this->rtc_.load(&recovered))

View File

@@ -194,7 +194,7 @@ void Cover::publish_state(bool save) {
} }
} }
optional<CoverRestoreState> Cover::restore_state_() { optional<CoverRestoreState> Cover::restore_state_() {
this->rtc_ = global_preferences->make_preference<CoverRestoreState>(this->get_object_id_hash()); this->rtc_ = global_preferences->make_preference<CoverRestoreState>(this->get_preference_hash());
CoverRestoreState recovered{}; CoverRestoreState recovered{};
if (!this->rtc_.load(&recovered)) if (!this->rtc_.load(&recovered))
return {}; return {};

View File

@@ -1,4 +1,5 @@
#ifdef USE_ESP32 #ifdef USE_ESP32
#include "soc/soc_caps.h"
#include "driver/gpio.h" #include "driver/gpio.h"
#include "deep_sleep_component.h" #include "deep_sleep_component.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -83,7 +84,11 @@ void DeepSleepComponent::deep_sleep_() {
} }
gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT);
gpio_hold_en(gpio_pin); gpio_hold_en(gpio_pin);
#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP
// Some ESP32 variants support holding a single GPIO during deep sleep without this function
// For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep
gpio_deep_sleep_hold_en(); gpio_deep_sleep_hold_en();
#endif
bool level = !this->wakeup_pin_->is_inverted(); bool level = !this->wakeup_pin_->is_inverted();
if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) {
level = !level; level = !level;
@@ -120,7 +125,11 @@ void DeepSleepComponent::deep_sleep_() {
} }
gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT);
gpio_hold_en(gpio_pin); gpio_hold_en(gpio_pin);
#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP
// Some ESP32 variants support holding a single GPIO during deep sleep without this function
// For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep
gpio_deep_sleep_hold_en(); gpio_deep_sleep_hold_en();
#endif
bool level = !this->wakeup_pin_->is_inverted(); bool level = !this->wakeup_pin_->is_inverted();
if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) {
level = !level; level = !level;

View File

@@ -41,7 +41,7 @@ void DutyTimeSensor::setup() {
uint32_t seconds = 0; uint32_t seconds = 0;
if (this->restore_) { if (this->restore_) {
this->pref_ = global_preferences->make_preference<uint32_t>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<uint32_t>(this->get_preference_hash());
this->pref_.load(&seconds); this->pref_.load(&seconds);
} }

View File

@@ -5,9 +5,14 @@ from esphome import automation
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME from esphome.const import (
CONF_ENABLE_ON_BOOT,
CONF_ESPHOME,
CONF_ID,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
)
from esphome.core import CORE, TimePeriod from esphome.core import CORE, TimePeriod
from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX
import esphome.final_validate as fv import esphome.final_validate as fv
DEPENDENCIES = ["esp32"] DEPENDENCIES = ["esp32"]
@@ -280,6 +285,10 @@ async def to_code(config):
add_idf_sdkconfig_option( add_idf_sdkconfig_option(
"CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds
) )
# Increase GATT client connection retry count for problematic devices
# Default in ESP-IDF is 3, we increase to 10 for better reliability with
# low-power/timing-sensitive devices
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10)
# Set the maximum number of notification registrations # Set the maximum number of notification registrations
# This controls how many BLE characteristics can have notifications enabled # This controls how many BLE characteristics can have notifications enabled

View File

@@ -7,6 +7,7 @@
#include <esp_gap_ble_api.h> #include <esp_gap_ble_api.h>
#include <esp_gatt_defs.h> #include <esp_gatt_defs.h>
#include <esp_gattc_api.h>
namespace esphome::esp32_ble_client { namespace esphome::esp32_ble_client {
@@ -111,43 +112,19 @@ void BLEClientBase::connect() {
this->remote_addr_type_); this->remote_addr_type_);
this->paired_ = false; this->paired_ = false;
// Set preferred connection parameters before connecting // Determine connection parameters based on connection type
// Use FAST for all V3 connections (better latency and reliability) if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
// Use MEDIUM for V1/legacy connections (balanced performance) // V3 without cache needs fast params for service discovery
uint16_t min_interval, max_interval, timeout; this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast");
const char *param_type; } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) {
// V3 with cache can use medium params
if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) {
min_interval = FAST_MIN_CONN_INTERVAL;
max_interval = FAST_MAX_CONN_INTERVAL;
timeout = FAST_CONN_TIMEOUT;
param_type = "fast";
} else {
min_interval = MEDIUM_MIN_CONN_INTERVAL;
max_interval = MEDIUM_MAX_CONN_INTERVAL;
timeout = MEDIUM_CONN_TIMEOUT;
param_type = "medium";
} }
// For V1/Legacy, don't set params - use ESP-IDF defaults
auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, // Open the connection
0, // latency: 0
timeout);
if (param_ret != ESP_OK) {
ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_,
this->address_str_.c_str(), param_ret);
} else {
this->log_connection_params_(param_type);
}
// Now open the connection
auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true);
if (ret) { this->handle_connection_result_(ret);
this->log_gattc_warning_("esp_ble_gattc_open", ret);
this->set_state(espbt::ClientState::IDLE);
} else {
this->set_state(espbt::ClientState::CONNECTING);
}
} }
esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); }
@@ -159,7 +136,8 @@ void BLEClientBase::disconnect() {
return; return;
} }
if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
this->log_warning_("Disconnect before connected, disconnect scheduled."); ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_,
this->address_str_.c_str());
this->want_disconnect_ = true; this->want_disconnect_ = true;
return; return;
} }
@@ -231,6 +209,15 @@ void BLEClientBase::log_connection_params_(const char *param_type) {
ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type);
} }
void BLEClientBase::handle_connection_result_(esp_err_t ret) {
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_open", ret);
this->set_state(espbt::ClientState::IDLE);
} else {
this->set_state(espbt::ClientState::CONNECTING);
}
}
void BLEClientBase::log_error_(const char *message) { void BLEClientBase::log_error_(const char *message) {
ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message);
} }
@@ -243,17 +230,30 @@ void BLEClientBase::log_warning_(const char *message) {
ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message);
} }
void BLEClientBase::restore_medium_conn_params_() { void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
// Restore to medium connection parameters after initial connection phase uint16_t timeout, const char *param_type) {
// This balances performance with bandwidth usage for normal operation
esp_ble_conn_update_params_t conn_params = {{0}}; esp_ble_conn_update_params_t conn_params = {{0}};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; conn_params.min_int = min_interval;
conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; conn_params.max_int = max_interval;
conn_params.latency = 0; conn_params.latency = latency;
conn_params.timeout = MEDIUM_CONN_TIMEOUT; conn_params.timeout = timeout;
this->log_connection_params_("medium"); this->log_connection_params_(param_type);
esp_ble_gap_update_conn_params(&conn_params); esp_err_t err = esp_ble_gap_update_conn_params(&conn_params);
if (err != ESP_OK) {
this->log_gattc_warning_("esp_ble_gap_update_conn_params", err);
}
}
void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type) {
// Set preferred connection parameters before connecting
// These will be used when establishing the connection
this->log_connection_params_(param_type);
esp_err_t err = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, latency, timeout);
if (err != ESP_OK) {
this->log_gattc_warning_("esp_ble_gap_set_prefer_conn_params", err);
}
} }
bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
@@ -285,11 +285,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_event_("OPEN"); this->log_gattc_event_("OPEN");
// conn_id was already set in ESP_GATTC_CONNECT_EVT // conn_id was already set in ESP_GATTC_CONNECT_EVT
this->service_count_ = 0; this->service_count_ = 0;
// ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an
// error, if the error occurred at the BTA/GATT layer. This can result in the event
// arriving after we've already transitioned to IDLE state.
if (this->state_ == espbt::ClientState::IDLE) {
ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_,
this->address_str_.c_str(), param->open.status);
break;
}
if (this->state_ != espbt::ClientState::CONNECTING) { if (this->state_ != espbt::ClientState::CONNECTING) {
// This should not happen but lets log it in case it does // This should not happen but lets log it in case it does
// because it means we have a bad assumption about how the // because it means we have a bad assumption about how the
// ESP BT stack works. // ESP BT stack works.
this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_,
this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status);
} }
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->log_gattc_warning_("Connection open", param->open.status); this->log_gattc_warning_("Connection open", param->open.status);
@@ -308,12 +319,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->set_state(espbt::ClientState::CONNECTED); this->set_state(espbt::ClientState::CONNECTED);
ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str());
if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) {
// Restore to medium connection parameters for cached connections too // Cached connections already connected with medium parameters, no update needed
this->restore_medium_conn_params_();
// only set our state, subclients might have more stuff to do yet. // only set our state, subclients might have more stuff to do yet.
this->state_ = espbt::ClientState::ESTABLISHED; this->state_ = espbt::ClientState::ESTABLISHED;
break; break;
} }
// For V3_WITHOUT_CACHE, we already set fast params before connecting
// No need to update them again here
this->log_event_("Searching for services"); this->log_event_("Searching for services");
esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr);
break; break;
@@ -395,12 +407,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
if (this->conn_id_ != param->search_cmpl.conn_id) if (this->conn_id_ != param->search_cmpl.conn_id)
return false; return false;
this->log_gattc_event_("SEARCH_CMPL"); this->log_gattc_event_("SEARCH_CMPL");
// For V3 connections, restore to medium connection parameters after service discovery // For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery
// This balances performance with bandwidth usage after the critical discovery phase // This balances performance with bandwidth usage after the critical discovery phase
if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
this->restore_medium_conn_params_(); } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) {
} else {
#ifdef USE_ESP32_BLE_DEVICE #ifdef USE_ESP32_BLE_DEVICE
for (auto &svc : this->services_) { for (auto &svc : this->services_) {
ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(),
@@ -484,6 +495,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
break; break;
} }
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
this->log_gattc_event_("UNREG_FOR_NOTIFY");
break;
}
default: default:
// ideally would check all other events for matching conn_id // ideally would check all other events for matching conn_id
ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_.c_str(), event); ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_.c_str(), event);

View File

@@ -133,10 +133,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_event_(const char *name); void log_event_(const char *name);
void log_gattc_event_(const char *name); void log_gattc_event_(const char *name);
void restore_medium_conn_params_(); void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_gatt_status_t status);
void log_gattc_warning_(const char *operation, esp_err_t err); void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type); void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
// Compact error logging helpers to reduce flash usage // Compact error logging helpers to reduce flash usage
void log_error_(const char *message); void log_error_(const char *message);
void log_error_(const char *message, int code); void log_error_(const char *message, int code);

View File

@@ -80,14 +80,17 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger<const adv_data_t &>,
ESPBTUUID uuid_; ESPBTUUID uuid_;
}; };
#endif // USE_ESP32_BLE_DEVICE
class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener {
public: public:
explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); }
#ifdef USE_ESP32_BLE_DEVICE
bool parse_device(const ESPBTDevice &device) override { return false; } bool parse_device(const ESPBTDevice &device) override { return false; }
#endif
void on_scan_end() override { this->trigger(); } void on_scan_end() override { this->trigger(); }
}; };
#endif // USE_ESP32_BLE_DEVICE
template<typename... Ts> class ESP32BLEStartScanAction : public Action<Ts...> { template<typename... Ts> class ESP32BLEStartScanAction : public Action<Ts...> {
public: public:

View File

@@ -148,7 +148,8 @@ void Fan::publish_state() {
constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABA; constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABA;
optional<FanRestoreState> Fan::restore_state_() { optional<FanRestoreState> Fan::restore_state_() {
FanRestoreState recovered{}; FanRestoreState recovered{};
this->rtc_ = global_preferences->make_preference<FanRestoreState>(this->get_object_id_hash() ^ RESTORE_STATE_VERSION); this->rtc_ =
global_preferences->make_preference<FanRestoreState>(this->get_preference_hash() ^ RESTORE_STATE_VERSION);
bool restored = this->rtc_.load(&recovered); bool restored = this->rtc_.load(&recovered);
switch (this->restore_mode_) { switch (this->restore_mode_) {

View File

@@ -351,7 +351,7 @@ ClimateTraits HaierClimateBase::traits() { return traits_; }
void HaierClimateBase::initialization() { void HaierClimateBase::initialization() {
constexpr uint32_t restore_settings_version = 0xA77D21EF; constexpr uint32_t restore_settings_version = 0xA77D21EF;
this->base_rtc_ = this->base_rtc_ =
global_preferences->make_preference<HaierBaseSettings>(this->get_object_id_hash() ^ restore_settings_version); global_preferences->make_preference<HaierBaseSettings>(this->get_preference_hash() ^ restore_settings_version);
HaierBaseSettings recovered; HaierBaseSettings recovered;
if (!this->base_rtc_.load(&recovered)) { if (!this->base_rtc_.load(&recovered)) {
recovered = {false, true}; recovered = {false, true};

View File

@@ -516,7 +516,7 @@ void HonClimate::initialization() {
HaierClimateBase::initialization(); HaierClimateBase::initialization();
constexpr uint32_t restore_settings_version = 0x57EB59DDUL; constexpr uint32_t restore_settings_version = 0x57EB59DDUL;
this->hon_rtc_ = this->hon_rtc_ =
global_preferences->make_preference<HonSettings>(this->get_object_id_hash() ^ restore_settings_version); global_preferences->make_preference<HonSettings>(this->get_preference_hash() ^ restore_settings_version);
HonSettings recovered; HonSettings recovered;
if (this->hon_rtc_.load(&recovered)) { if (this->hon_rtc_.load(&recovered)) {
this->settings_ = recovered; this->settings_ = recovered;

View File

@@ -1,7 +1,10 @@
#include "http_request_host.h"
#ifdef USE_HOST #ifdef USE_HOST
#define USE_HTTP_REQUEST_HOST_H
#define CPPHTTPLIB_NO_EXCEPTIONS
#include "httplib.h"
#include "http_request_host.h"
#include <regex> #include <regex>
#include "esphome/components/network/util.h" #include "esphome/components/network/util.h"
#include "esphome/components/watchdog/watchdog.h" #include "esphome/components/watchdog/watchdog.h"

View File

@@ -1,11 +1,7 @@
#pragma once #pragma once
#include "http_request.h"
#ifdef USE_HOST #ifdef USE_HOST
#include "http_request.h"
#define CPPHTTPLIB_NO_EXCEPTIONS
#include "httplib.h"
namespace esphome { namespace esphome {
namespace http_request { namespace http_request {

View File

@@ -3,12 +3,10 @@
/** /**
* NOTE: This is a copy of httplib.h from https://github.com/yhirose/cpp-httplib * NOTE: This is a copy of httplib.h from https://github.com/yhirose/cpp-httplib
* *
* It has been modified only to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, * It has been modified to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome,
* it was considered preferable to use it with as few changes as possible, to facilitate future updates. * it was considered preferable to use it with as few changes as possible, to facilitate future updates.
*/ */
#include "esphome/core/defines.h"
// //
// httplib.h // httplib.h
// //
@@ -17,6 +15,11 @@
// //
#ifdef USE_HOST #ifdef USE_HOST
// Prevent this code being included in main.cpp
#ifdef USE_HTTP_REQUEST_HOST_H
#include "esphome/core/defines.h"
#ifndef CPPHTTPLIB_HTTPLIB_H #ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H
@@ -9687,5 +9690,6 @@ inline SSL_CTX *Client::ssl_context() const {
#endif #endif
#endif // CPPHTTPLIB_HTTPLIB_H #endif // CPPHTTPLIB_HTTPLIB_H
#endif // USE_HTTP_REQUEST_HOST_H
#endif #endif

View File

@@ -10,7 +10,7 @@ static const char *const TAG = "integration";
void IntegrationSensor::setup() { void IntegrationSensor::setup() {
if (this->restore_) { if (this->restore_) {
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
float preference_value = 0; float preference_value = 0;
this->pref_.load(&preference_value); this->pref_.load(&preference_value);
this->result_ = preference_value; this->result_ = preference_value;

View File

@@ -184,7 +184,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui
void LD2450Component::setup() { void LD2450Component::setup() {
#ifdef USE_NUMBER #ifdef USE_NUMBER
if (this->presence_timeout_number_ != nullptr) { if (this->presence_timeout_number_ != nullptr) {
this->pref_ = global_preferences->make_preference<float>(this->presence_timeout_number_->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->presence_timeout_number_->get_preference_hash());
this->set_presence_timeout(); this->set_presence_timeout();
} }
#endif #endif

View File

@@ -41,7 +41,7 @@ void LightState::setup() {
case LIGHT_RESTORE_DEFAULT_ON: case LIGHT_RESTORE_DEFAULT_ON:
case LIGHT_RESTORE_INVERTED_DEFAULT_OFF: case LIGHT_RESTORE_INVERTED_DEFAULT_OFF:
case LIGHT_RESTORE_INVERTED_DEFAULT_ON: case LIGHT_RESTORE_INVERTED_DEFAULT_ON:
this->rtc_ = global_preferences->make_preference<LightStateRTCState>(this->get_object_id_hash()); this->rtc_ = global_preferences->make_preference<LightStateRTCState>(this->get_preference_hash());
// Attempt to load from preferences, else fall back to default values // Attempt to load from preferences, else fall back to default values
if (!this->rtc_.load(&recovered)) { if (!this->rtc_.load(&recovered)) {
recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON ||
@@ -54,7 +54,7 @@ void LightState::setup() {
break; break;
case LIGHT_RESTORE_AND_OFF: case LIGHT_RESTORE_AND_OFF:
case LIGHT_RESTORE_AND_ON: case LIGHT_RESTORE_AND_ON:
this->rtc_ = global_preferences->make_preference<LightStateRTCState>(this->get_object_id_hash()); this->rtc_ = global_preferences->make_preference<LightStateRTCState>(this->get_preference_hash());
this->rtc_.load(&recovered); this->rtc_.load(&recovered);
recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON);
break; break;

View File

@@ -21,7 +21,7 @@ class LVGLNumber : public number::Number, public Component {
void setup() override { void setup() override {
float value = this->value_lambda_(); float value = this->value_lambda_();
if (this->restore_) { if (this->restore_) {
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
if (this->pref_.load(&value)) { if (this->pref_.load(&value)) {
this->control_lambda_(value); this->control_lambda_(value);
} }

View File

@@ -20,7 +20,7 @@ class LVGLSelect : public select::Select, public Component {
this->set_options_(); this->set_options_();
if (this->restore_) { if (this->restore_) {
size_t index; size_t index;
this->pref_ = global_preferences->make_preference<size_t>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<size_t>(this->get_preference_hash());
if (this->pref_.load(&index)) if (this->pref_.load(&index))
this->widget_->set_selected_index(index, LV_ANIM_OFF); this->widget_->set_selected_index(index, LV_ANIM_OFF);
} }

View File

@@ -24,7 +24,7 @@ from ..defines import (
literal, literal,
) )
from ..lv_validation import ( from ..lv_validation import (
lv_angle, lv_angle_degrees,
lv_bool, lv_bool,
lv_color, lv_color,
lv_image, lv_image,
@@ -395,15 +395,15 @@ ARC_PROPS = {
DRAW_OPA_SCHEMA.extend( DRAW_OPA_SCHEMA.extend(
{ {
cv.Required(CONF_RADIUS): pixels, cv.Required(CONF_RADIUS): pixels,
cv.Required(CONF_START_ANGLE): lv_angle, cv.Required(CONF_START_ANGLE): lv_angle_degrees,
cv.Required(CONF_END_ANGLE): lv_angle, cv.Required(CONF_END_ANGLE): lv_angle_degrees,
} }
).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}), ).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}),
) )
async def canvas_draw_arc(config, action_id, template_arg, args): async def canvas_draw_arc(config, action_id, template_arg, args):
radius = await size.process(config[CONF_RADIUS]) radius = await size.process(config[CONF_RADIUS])
start_angle = await lv_angle.process(config[CONF_START_ANGLE]) start_angle = await lv_angle_degrees.process(config[CONF_START_ANGLE])
end_angle = await lv_angle.process(config[CONF_END_ANGLE]) end_angle = await lv_angle_degrees.process(config[CONF_END_ANGLE])
async def do_draw_arc(w: Widget, x, y, dsc_addr): async def do_draw_arc(w: Widget, x, y, dsc_addr):
lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr) lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr)

View File

@@ -14,7 +14,6 @@ from esphome.const import (
CONF_VALUE, CONF_VALUE,
CONF_WIDTH, CONF_WIDTH,
) )
from esphome.cpp_generator import IntLiteral
from ..automation import action_to_code from ..automation import action_to_code
from ..defines import ( from ..defines import (
@@ -32,7 +31,7 @@ from ..helpers import add_lv_use, lvgl_components_required
from ..lv_validation import ( from ..lv_validation import (
get_end_value, get_end_value,
get_start_value, get_start_value,
lv_angle, lv_angle_degrees,
lv_bool, lv_bool,
lv_color, lv_color,
lv_float, lv_float,
@@ -163,7 +162,7 @@ SCALE_SCHEMA = cv.Schema(
cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_, cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_,
cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_, cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_,
cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360), cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360),
cv.Optional(CONF_ROTATION): lv_angle, cv.Optional(CONF_ROTATION): lv_angle_degrees,
cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA), cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA),
} }
) )
@@ -188,9 +187,7 @@ class MeterType(WidgetType):
for scale_conf in config.get(CONF_SCALES, ()): for scale_conf in config.get(CONF_SCALES, ()):
rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2
if CONF_ROTATION in scale_conf: if CONF_ROTATION in scale_conf:
rotation = await lv_angle.process(scale_conf[CONF_ROTATION]) rotation = await lv_angle_degrees.process(scale_conf[CONF_ROTATION])
if isinstance(rotation, IntLiteral):
rotation = int(str(rotation)) // 10
with LocalVariable( with LocalVariable(
"meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var)
) as meter_var: ) as meter_var:

View File

@@ -255,4 +255,233 @@ DriverChip(
), ),
) )
DriverChip(
"JC3636W518V2",
height=360,
width=360,
offset_height=1,
draw_rounding=1,
cs_pin=10,
reset_pin=47,
invert_colors=True,
color_order=MODE_RGB,
bus_mode=TYPE_QUAD,
data_rate="40MHz",
initsequence=(
(0xF0, 0x28),
(0xF2, 0x28),
(0x73, 0xF0),
(0x7C, 0xD1),
(0x83, 0xE0),
(0x84, 0x61),
(0xF2, 0x82),
(0xF0, 0x00),
(0xF0, 0x01),
(0xF1, 0x01),
(0xB0, 0x56),
(0xB1, 0x4D),
(0xB2, 0x24),
(0xB4, 0x87),
(0xB5, 0x44),
(0xB6, 0x8B),
(0xB7, 0x40),
(0xB8, 0x86),
(0xBA, 0x00),
(0xBB, 0x08),
(0xBC, 0x08),
(0xBD, 0x00),
(0xC0, 0x80),
(0xC1, 0x10),
(0xC2, 0x37),
(0xC3, 0x80),
(0xC4, 0x10),
(0xC5, 0x37),
(0xC6, 0xA9),
(0xC7, 0x41),
(0xC8, 0x01),
(0xC9, 0xA9),
(0xCA, 0x41),
(0xCB, 0x01),
(0xD0, 0x91),
(0xD1, 0x68),
(0xD2, 0x68),
(0xF5, 0x00, 0xA5),
(0xDD, 0x4F),
(0xDE, 0x4F),
(0xF1, 0x10),
(0xF0, 0x00),
(0xF0, 0x02),
(
0xE0,
0xF0,
0x0A,
0x10,
0x09,
0x09,
0x36,
0x35,
0x33,
0x4A,
0x29,
0x15,
0x15,
0x2E,
0x34,
),
(
0xE1,
0xF0,
0x0A,
0x0F,
0x08,
0x08,
0x05,
0x34,
0x33,
0x4A,
0x39,
0x15,
0x15,
0x2D,
0x33,
),
(0xF0, 0x10),
(0xF3, 0x10),
(0xE0, 0x07),
(0xE1, 0x00),
(0xE2, 0x00),
(0xE3, 0x00),
(0xE4, 0xE0),
(0xE5, 0x06),
(0xE6, 0x21),
(0xE7, 0x01),
(0xE8, 0x05),
(0xE9, 0x02),
(0xEA, 0xDA),
(0xEB, 0x00),
(0xEC, 0x00),
(0xED, 0x0F),
(0xEE, 0x00),
(0xEF, 0x00),
(0xF8, 0x00),
(0xF9, 0x00),
(0xFA, 0x00),
(0xFB, 0x00),
(0xFC, 0x00),
(0xFD, 0x00),
(0xFE, 0x00),
(0xFF, 0x00),
(0x60, 0x40),
(0x61, 0x04),
(0x62, 0x00),
(0x63, 0x42),
(0x64, 0xD9),
(0x65, 0x00),
(0x66, 0x00),
(0x67, 0x00),
(0x68, 0x00),
(0x69, 0x00),
(0x6A, 0x00),
(0x6B, 0x00),
(0x70, 0x40),
(0x71, 0x03),
(0x72, 0x00),
(0x73, 0x42),
(0x74, 0xD8),
(0x75, 0x00),
(0x76, 0x00),
(0x77, 0x00),
(0x78, 0x00),
(0x79, 0x00),
(0x7A, 0x00),
(0x7B, 0x00),
(0x80, 0x48),
(0x81, 0x00),
(0x82, 0x06),
(0x83, 0x02),
(0x84, 0xD6),
(0x85, 0x04),
(0x86, 0x00),
(0x87, 0x00),
(0x88, 0x48),
(0x89, 0x00),
(0x8A, 0x08),
(0x8B, 0x02),
(0x8C, 0xD8),
(0x8D, 0x04),
(0x8E, 0x00),
(0x8F, 0x00),
(0x90, 0x48),
(0x91, 0x00),
(0x92, 0x0A),
(0x93, 0x02),
(0x94, 0xDA),
(0x95, 0x04),
(0x96, 0x00),
(0x97, 0x00),
(0x98, 0x48),
(0x99, 0x00),
(0x9A, 0x0C),
(0x9B, 0x02),
(0x9C, 0xDC),
(0x9D, 0x04),
(0x9E, 0x00),
(0x9F, 0x00),
(0xA0, 0x48),
(0xA1, 0x00),
(0xA2, 0x05),
(0xA3, 0x02),
(0xA4, 0xD5),
(0xA5, 0x04),
(0xA6, 0x00),
(0xA7, 0x00),
(0xA8, 0x48),
(0xA9, 0x00),
(0xAA, 0x07),
(0xAB, 0x02),
(0xAC, 0xD7),
(0xAD, 0x04),
(0xAE, 0x00),
(0xAF, 0x00),
(0xB0, 0x48),
(0xB1, 0x00),
(0xB2, 0x09),
(0xB3, 0x02),
(0xB4, 0xD9),
(0xB5, 0x04),
(0xB6, 0x00),
(0xB7, 0x00),
(0xB8, 0x48),
(0xB9, 0x00),
(0xBA, 0x0B),
(0xBB, 0x02),
(0xBC, 0xDB),
(0xBD, 0x04),
(0xBE, 0x00),
(0xBF, 0x00),
(0xC0, 0x10),
(0xC1, 0x47),
(0xC2, 0x56),
(0xC3, 0x65),
(0xC4, 0x74),
(0xC5, 0x88),
(0xC6, 0x99),
(0xC7, 0x01),
(0xC8, 0xBB),
(0xC9, 0xAA),
(0xD0, 0x10),
(0xD1, 0x47),
(0xD2, 0x56),
(0xD3, 0x65),
(0xD4, 0x74),
(0xD5, 0x88),
(0xD6, 0x99),
(0xD7, 0x01),
(0xD8, 0xBB),
(0xD9, 0xAA),
(0xF3, 0x01),
(0xF0, 0x00),
),
)
models = {} models = {}

View File

@@ -119,8 +119,8 @@ async def to_code(config: ConfigType) -> None:
cg.add_platformio_option( cg.add_platformio_option(
"platform_packages", "platform_packages",
[ [
"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip", "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-7.zip",
"platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.16.1-1.zip", "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip",
], ],
) )

View File

@@ -15,7 +15,7 @@ void ValueRangeTrigger::setup() {
float local_min = this->min_.value(0.0); float local_min = this->min_.value(0.0);
float local_max = this->max_.value(0.0); float local_max = this->max_.value(0.0);
convert hash = {.from = (local_max - local_min)}; convert hash = {.from = (local_max - local_min)};
uint32_t myhash = hash.to ^ this->parent_->get_object_id_hash(); uint32_t myhash = hash.to ^ this->parent_->get_preference_hash();
this->rtc_ = global_preferences->make_preference<bool>(myhash); this->rtc_ = global_preferences->make_preference<bool>(myhash);
bool initial_state; bool initial_state;
if (this->rtc_.load(&initial_state)) { if (this->rtc_.load(&initial_state)) {

View File

@@ -17,7 +17,7 @@ void OpenthermNumber::setup() {
if (!this->restore_value_) { if (!this->restore_value_) {
value = this->initial_value_; value = this->initial_value_;
} else { } else {
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
if (!this->pref_.load(&value)) { if (!this->pref_.load(&value)) {
if (!std::isnan(this->initial_value_)) { if (!std::isnan(this->initial_value_)) {
value = this->initial_value_; value = this->initial_value_;

View File

@@ -46,10 +46,32 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t
} }
this->connection_established_ = true; this->connection_established_ = true;
this->char_handle_ = chr->handle; this->char_handle_ = chr->handle;
#ifdef USE_TIME
this->sync_time_(); // Attempt to write immediately
#endif // For devices without security, this will work
this->display(); // For devices with security that are already paired, this will work
// For devices that need pairing, the write will be retried after auth completes
this->sync_time_and_display_();
break;
}
default:
break;
}
}
void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
switch (event) {
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr))
return;
if (param->ble_security.auth_cmpl.success) {
ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str());
// Now that pairing is complete, perform the pending writes
this->sync_time_and_display_();
} else {
ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str().c_str());
}
break; break;
} }
default: default:
@@ -127,6 +149,13 @@ void PVVXDisplay::delayed_disconnect_() {
this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); });
} }
void PVVXDisplay::sync_time_and_display_() {
#ifdef USE_TIME
this->sync_time_();
#endif
this->display();
}
#ifdef USE_TIME #ifdef USE_TIME
void PVVXDisplay::sync_time_() { void PVVXDisplay::sync_time_() {
if (this->time_ == nullptr) if (this->time_ == nullptr)

View File

@@ -43,6 +43,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent {
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override; esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
/// Set validity period of the display information in seconds (1..65535) /// Set validity period of the display information in seconds (1..65535)
void set_validity_period(uint16_t validity_period) { this->validity_period_ = validity_period; } void set_validity_period(uint16_t validity_period) { this->validity_period_ = validity_period; }
@@ -112,6 +113,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent {
void setcfgbit_(uint8_t bit, bool value); void setcfgbit_(uint8_t bit, bool value);
void send_to_setup_char_(uint8_t *blk, size_t size); void send_to_setup_char_(uint8_t *blk, size_t size);
void delayed_disconnect_(); void delayed_disconnect_();
void sync_time_and_display_();
#ifdef USE_TIME #ifdef USE_TIME
void sync_time_(); void sync_time_();
time::RealTimeClock *time_{nullptr}; time::RealTimeClock *time_{nullptr};

View File

@@ -132,7 +132,7 @@ void RotaryEncoderSensor::setup() {
int32_t initial_value = 0; int32_t initial_value = 0;
switch (this->restore_mode_) { switch (this->restore_mode_) {
case ROTARY_ENCODER_RESTORE_DEFAULT_ZERO: case ROTARY_ENCODER_RESTORE_DEFAULT_ZERO:
this->rtc_ = global_preferences->make_preference<int32_t>(this->get_object_id_hash()); this->rtc_ = global_preferences->make_preference<int32_t>(this->get_preference_hash());
if (!this->rtc_.load(&initial_value)) { if (!this->rtc_.load(&initial_value)) {
initial_value = 0; initial_value = 0;
} }

View File

@@ -1,6 +1,5 @@
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import modbus, sensor from esphome.components import modbus, sensor
from esphome.components.atm90e32.sensor import CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ACTIVE_POWER, CONF_ACTIVE_POWER,
@@ -12,7 +11,10 @@ from esphome.const import (
CONF_ID, CONF_ID,
CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_ACTIVE_ENERGY,
CONF_IMPORT_REACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY,
CONF_PHASE_A,
CONF_PHASE_ANGLE, CONF_PHASE_ANGLE,
CONF_PHASE_B,
CONF_PHASE_C,
CONF_POWER_FACTOR, CONF_POWER_FACTOR,
CONF_REACTIVE_POWER, CONF_REACTIVE_POWER,
CONF_TOTAL_POWER, CONF_TOTAL_POWER,

View File

@@ -40,7 +40,7 @@ class ValueRangeTrigger : public Trigger<float>, public Component {
template<typename V> void set_max(V max) { this->max_ = max; } template<typename V> void set_max(V max) { this->max_ = max; }
void setup() override { void setup() override {
this->rtc_ = global_preferences->make_preference<bool>(this->parent_->get_object_id_hash()); this->rtc_ = global_preferences->make_preference<bool>(this->parent_->get_preference_hash());
bool initial_state; bool initial_state;
if (this->rtc_.load(&initial_state)) { if (this->rtc_.load(&initial_state)) {
this->previous_in_range_ = initial_state; this->previous_in_range_ = initial_state;

View File

@@ -55,7 +55,7 @@ void SpeakerMediaPlayer::setup() {
this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand)); this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand));
this->pref_ = global_preferences->make_preference<VolumeRestoreState>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<VolumeRestoreState>(this->get_preference_hash());
VolumeRestoreState volume_restore_state; VolumeRestoreState volume_restore_state;
if (this->pref_.load(&volume_restore_state)) { if (this->pref_.load(&volume_restore_state)) {

View File

@@ -81,7 +81,7 @@ void SprinklerControllerNumber::setup() {
if (!this->restore_value_) { if (!this->restore_value_) {
value = this->initial_value_; value = this->initial_value_;
} else { } else {
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
if (!this->pref_.load(&value)) { if (!this->pref_.load(&value)) {
if (!std::isnan(this->initial_value_)) { if (!std::isnan(this->initial_value_)) {
value = this->initial_value_; value = this->initial_value_;

View File

@@ -32,7 +32,7 @@ optional<bool> Switch::get_initial_state() {
if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK)) if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK))
return {}; return {};
this->rtc_ = global_preferences->make_preference<bool>(this->get_object_id_hash()); this->rtc_ = global_preferences->make_preference<bool>(this->get_preference_hash());
bool initial_state; bool initial_state;
if (!this->rtc_.load(&initial_state)) if (!this->rtc_.load(&initial_state))
return {}; return {};

View File

@@ -86,7 +86,7 @@ void TemplateAlarmControlPanel::setup() {
break; break;
case ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED: { case ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED: {
uint8_t value; uint8_t value;
this->pref_ = global_preferences->make_preference<uint8_t>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<uint8_t>(this->get_preference_hash());
if (this->pref_.load(&value)) { if (this->pref_.load(&value)) {
this->current_state_ = static_cast<alarm_control_panel::AlarmControlPanelState>(value); this->current_state_ = static_cast<alarm_control_panel::AlarmControlPanelState>(value);
} else { } else {

View File

@@ -20,7 +20,7 @@ void TemplateDate::setup() {
} else { } else {
datetime::DateEntityRestoreState temp; datetime::DateEntityRestoreState temp;
this->pref_ = this->pref_ =
global_preferences->make_preference<datetime::DateEntityRestoreState>(194434030U ^ this->get_object_id_hash()); global_preferences->make_preference<datetime::DateEntityRestoreState>(194434030U ^ this->get_preference_hash());
if (this->pref_.load(&temp)) { if (this->pref_.load(&temp)) {
temp.apply(this); temp.apply(this);
return; return;

View File

@@ -19,8 +19,8 @@ void TemplateDateTime::setup() {
state = this->initial_value_; state = this->initial_value_;
} else { } else {
datetime::DateTimeEntityRestoreState temp; datetime::DateTimeEntityRestoreState temp;
this->pref_ = global_preferences->make_preference<datetime::DateTimeEntityRestoreState>(194434090U ^ this->pref_ = global_preferences->make_preference<datetime::DateTimeEntityRestoreState>(
this->get_object_id_hash()); 194434090U ^ this->get_preference_hash());
if (this->pref_.load(&temp)) { if (this->pref_.load(&temp)) {
temp.apply(this); temp.apply(this);
return; return;

View File

@@ -20,7 +20,7 @@ void TemplateTime::setup() {
} else { } else {
datetime::TimeEntityRestoreState temp; datetime::TimeEntityRestoreState temp;
this->pref_ = this->pref_ =
global_preferences->make_preference<datetime::TimeEntityRestoreState>(194434060U ^ this->get_object_id_hash()); global_preferences->make_preference<datetime::TimeEntityRestoreState>(194434060U ^ this->get_preference_hash());
if (this->pref_.load(&temp)) { if (this->pref_.load(&temp)) {
temp.apply(this); temp.apply(this);
return; return;

View File

@@ -14,7 +14,7 @@ void TemplateNumber::setup() {
if (!this->restore_value_) { if (!this->restore_value_) {
value = this->initial_value_; value = this->initial_value_;
} else { } else {
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
if (!this->pref_.load(&value)) { if (!this->pref_.load(&value)) {
if (!std::isnan(this->initial_value_)) { if (!std::isnan(this->initial_value_)) {
value = this->initial_value_; value = this->initial_value_;

View File

@@ -16,7 +16,7 @@ void TemplateSelect::setup() {
ESP_LOGD(TAG, "State from initial: %s", value.c_str()); ESP_LOGD(TAG, "State from initial: %s", value.c_str());
} else { } else {
size_t index; size_t index;
this->pref_ = global_preferences->make_preference<size_t>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<size_t>(this->get_preference_hash());
if (!this->pref_.load(&index)) { if (!this->pref_.load(&index)) {
value = this->initial_option_; value = this->initial_option_;
ESP_LOGD(TAG, "State from initial (could not load stored index): %s", value.c_str()); ESP_LOGD(TAG, "State from initial (could not load stored index): %s", value.c_str());

View File

@@ -15,7 +15,7 @@ void TemplateText::setup() {
if (!this->pref_) { if (!this->pref_) {
ESP_LOGD(TAG, "State from initial: %s", value.c_str()); ESP_LOGD(TAG, "State from initial: %s", value.c_str());
} else { } else {
uint32_t key = this->get_object_id_hash(); uint32_t key = this->get_preference_hash();
key += this->traits.get_min_length() << 2; key += this->traits.get_min_length() << 2;
key += this->traits.get_max_length() << 4; key += this->traits.get_max_length() << 4;
key += fnv1_hash(this->traits.get_pattern()) << 6; key += fnv1_hash(this->traits.get_pattern()) << 6;

View File

@@ -10,7 +10,7 @@ void TotalDailyEnergy::setup() {
float initial_value = 0; float initial_value = 0;
if (this->restore_) { if (this->restore_) {
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
this->pref_.load(&initial_value); this->pref_.load(&initial_value);
} }
this->publish_state_and_save(initial_value); this->publish_state_and_save(initial_value);

View File

@@ -8,7 +8,7 @@ static const char *const TAG = "tuya.number";
void TuyaNumber::setup() { void TuyaNumber::setup() {
if (this->restore_value_) { if (this->restore_value_) {
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash()); this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
} }
this->parent_->register_listener(this->number_id_, [this](const TuyaDatapoint &datapoint) { this->parent_->register_listener(this->number_id_, [this](const TuyaDatapoint &datapoint) {

View File

@@ -155,7 +155,7 @@ void Valve::publish_state(bool save) {
} }
} }
optional<ValveRestoreState> Valve::restore_state_() { optional<ValveRestoreState> Valve::restore_state_() {
this->rtc_ = global_preferences->make_preference<ValveRestoreState>(this->get_object_id_hash()); this->rtc_ = global_preferences->make_preference<ValveRestoreState>(this->get_preference_hash());
ValveRestoreState recovered{}; ValveRestoreState recovered{};
if (!this->rtc_.load(&recovered)) if (!this->rtc_.load(&recovered))
return {}; return {};

View File

@@ -52,9 +52,9 @@ def default_url(config: ConfigType) -> ConfigType:
config = config.copy() config = config.copy()
if config[CONF_VERSION] == 1: if config[CONF_VERSION] == 1:
if CONF_CSS_URL not in config: if CONF_CSS_URL not in config:
config[CONF_CSS_URL] = "https://esphome.io/_static/webserver-v1.min.css" config[CONF_CSS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.css"
if CONF_JS_URL not in config: if CONF_JS_URL not in config:
config[CONF_JS_URL] = "https://esphome.io/_static/webserver-v1.min.js" config[CONF_JS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.js"
if config[CONF_VERSION] == 2: if config[CONF_VERSION] == 2:
if CONF_CSS_URL not in config: if CONF_CSS_URL not in config:
config[CONF_CSS_URL] = "" config[CONF_CSS_URL] = ""

View File

@@ -173,14 +173,14 @@ class WebServer : public Controller, public Component, public AsyncWebHandler {
#if USE_WEBSERVER_VERSION == 1 #if USE_WEBSERVER_VERSION == 1
/** Set the URL to the CSS <link> that's sent to each client. Defaults to /** Set the URL to the CSS <link> that's sent to each client. Defaults to
* https://esphome.io/_static/webserver-v1.min.css * https://oi.esphome.io/v1/webserver-v1.min.css
* *
* @param css_url The url to the web server stylesheet. * @param css_url The url to the web server stylesheet.
*/ */
void set_css_url(const char *css_url); void set_css_url(const char *css_url);
/** Set the URL to the script that's embedded in the index page. Defaults to /** Set the URL to the script that's embedded in the index page. Defaults to
* https://esphome.io/_static/webserver-v1.min.js * https://oi.esphome.io/v1/webserver-v1.min.js
* *
* @param js_url The url to the web server script. * @param js_url The url to the web server script.
*/ */

View File

@@ -253,7 +253,7 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
esp_crypto_base64_encode(reinterpret_cast<uint8_t *>(digest.get()), n, &out, esp_crypto_base64_encode(reinterpret_cast<uint8_t *>(digest.get()), n, &out,
reinterpret_cast<const uint8_t *>(user_info.c_str()), user_info.size()); reinterpret_cast<const uint8_t *>(user_info.c_str()), user_info.size());
return strncmp(digest.get(), auth_str + auth_prefix_len, auth.value().size() - auth_prefix_len) == 0; return strcmp(digest.get(), auth_str + auth_prefix_len) == 0;
} }
void AsyncWebServerRequest::requestAuthentication(const char *realm) const { void AsyncWebServerRequest::requestAuthentication(const char *realm) const {

View File

@@ -1112,8 +1112,8 @@ voltage = float_with_unit("voltage", "(v|V|volt|Volts)?")
distance = float_with_unit("distance", "(m)") distance = float_with_unit("distance", "(m)")
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)") framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
angle = float_with_unit("angle", "(°|deg)", optional_unit=True) angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
_temperature_c = float_with_unit("temperature", "(°C|° C|°|C)?") _temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
_temperature_k = float_with_unit("temperature", " K|° K|K)?") _temperature_k = float_with_unit("temperature", "(°K|° K|K)?")
_temperature_f = float_with_unit("temperature", "(°F|° F|F)?") _temperature_f = float_with_unit("temperature", "(°F|° F|F)?")
decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True) decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True)
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True) pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)

View File

@@ -5,6 +5,8 @@
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#include "esphome/core/preferences.h" #include "esphome/core/preferences.h"
#include "esphome/core/scheduler.h"
#include "esphome/core/application.h"
#include <vector> #include <vector>
@@ -158,7 +160,16 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
void play_complex(Ts... x) override { void play_complex(Ts... x) override {
auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...); auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...);
this->num_running_++; this->num_running_++;
this->set_timeout("delay", this->delay_.value(x...), f);
// If num_running_ > 1, we have multiple instances running in parallel
// In single/restart/queued modes, only one instance runs at a time
// Parallel mode uses skip_cancel=true to allow multiple delays to coexist
// WARNING: This can accumulate delays if scripts are triggered faster than they complete!
// Users should set max_runs on parallel scripts to limit concurrent executions.
// Issue #10264: This is a workaround for parallel script delays interfering with each other.
App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT,
/* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f),
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
} }
float get_setup_priority() const override { return setup_priority::HARDWARE; } float get_setup_priority() const override { return setup_priority::HARDWARE; }

View File

@@ -85,6 +85,35 @@ class EntityBase {
// Set has_state - for components that need to manually set this // Set has_state - for components that need to manually set this
void set_has_state(bool state) { this->flags_.has_state = state; } void set_has_state(bool state) { this->flags_.has_state = state; }
/**
* @brief Get a unique hash for storing preferences/settings for this entity.
*
* This method returns a hash that uniquely identifies the entity for the purpose of
* storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(),
* this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness
* across multiple devices that may have entities with the same object_id.
*
* Use this method when storing or retrieving preferences/settings that should be unique
* per device-entity pair. Use get_object_id_hash() when you need a hash that identifies
* the entity regardless of the device it belongs to.
*
* For backward compatibility, if device_id is 0 (the main device), the hash is unchanged
* from previous versions, so existing single-device configurations will continue to work.
*
* @return uint32_t The unique hash for preferences, including device_id if available.
*/
uint32_t get_preference_hash() {
#ifdef USE_DEVICES
// Combine object_id_hash with device_id to ensure uniqueness across devices
// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash
// This ensures backward compatibility for existing single-device configurations
return this->get_object_id_hash() ^ this->get_device_id();
#else
// Without devices, just use object_id_hash as before
return this->get_object_id_hash();
#endif
}
protected: protected:
friend class api::APIConnection; friend class api::APIConnection;

View File

@@ -236,10 +236,21 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
if existing_component != "unknown": if existing_component != "unknown":
conflict_msg += f" from component '{existing_component}'" conflict_msg += f" from component '{existing_component}'"
# Show both original names and their ASCII-only versions if they differ
sanitized_msg = ""
if entity_name != existing_name:
sanitized_msg = (
f"\n Original names: '{entity_name}' and '{existing_name}'"
f"\n Both convert to ASCII ID: '{name_key}'"
"\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')"
"\n to distinguish them"
)
raise cv.Invalid( raise cv.Invalid(
f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. "
f"{conflict_msg}. " f"{conflict_msg}. "
f"Each entity on a device must have a unique name within its platform." "Each entity on a device must have a unique name within its platform."
f"{sanitized_msg}"
) )
# Store metadata about this entity # Store metadata about this entity

View File

@@ -65,14 +65,17 @@ static void validate_static_string(const char *name) {
// Common implementation for both timeout and interval // Common implementation for both timeout and interval
void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string,
const void *name_ptr, uint32_t delay, std::function<void()> func, bool is_retry) { const void *name_ptr, uint32_t delay, std::function<void()> func, bool is_retry,
bool skip_cancel) {
// Get the name as const char* // Get the name as const char*
const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr);
if (delay == SCHEDULER_DONT_RUN) { if (delay == SCHEDULER_DONT_RUN) {
// Still need to cancel existing timer if name is not empty // Still need to cancel existing timer if name is not empty
LockGuard guard{this->lock_}; if (!skip_cancel) {
this->cancel_item_locked_(component, name_cstr, type); LockGuard guard{this->lock_};
this->cancel_item_locked_(component, name_cstr, type);
}
return; return;
} }
@@ -97,7 +100,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
if (delay == 0 && type == SchedulerItem::TIMEOUT) { if (delay == 0 && type == SchedulerItem::TIMEOUT) {
// Put in defer queue for guaranteed FIFO execution // Put in defer queue for guaranteed FIFO execution
LockGuard guard{this->lock_}; LockGuard guard{this->lock_};
this->cancel_item_locked_(component, name_cstr, type); if (!skip_cancel) {
this->cancel_item_locked_(component, name_cstr, type);
}
this->defer_queue_.push_back(std::move(item)); this->defer_queue_.push_back(std::move(item));
return; return;
} }
@@ -150,9 +155,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
return; return;
} }
// If name is provided, do atomic cancel-and-add // If name is provided, do atomic cancel-and-add (unless skip_cancel is true)
// Cancel existing items // Cancel existing items
this->cancel_item_locked_(component, name_cstr, type); if (!skip_cancel) {
this->cancel_item_locked_(component, name_cstr, type);
}
// Add new item directly to to_add_ // Add new item directly to to_add_
// since we have the lock held // since we have the lock held
this->to_add_.push_back(std::move(item)); this->to_add_.push_back(std::move(item));

View File

@@ -21,8 +21,13 @@ struct RetryArgs;
void retry_handler(const std::shared_ptr<RetryArgs> &args); void retry_handler(const std::shared_ptr<RetryArgs> &args);
class Scheduler { class Scheduler {
// Allow retry_handler to access protected members // Allow retry_handler to access protected members for internal retry mechanism
friend void ::esphome::retry_handler(const std::shared_ptr<RetryArgs> &args); friend void ::esphome::retry_handler(const std::shared_ptr<RetryArgs> &args);
// Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays.
// This is needed to fix issue #10264 where parallel scripts with delays interfere with each other.
// We use friend instead of a public API because skip_cancel is dangerous - it can cause delays
// to accumulate and overload the scheduler if misused.
template<typename... Ts> friend class DelayAction;
public: public:
// Public API - accepts std::string for backward compatibility // Public API - accepts std::string for backward compatibility
@@ -184,7 +189,7 @@ class Scheduler {
// Common implementation for both timeout and interval // Common implementation for both timeout and interval
void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr,
uint32_t delay, std::function<void()> func, bool is_retry = false); uint32_t delay, std::function<void()> func, bool is_retry = false, bool skip_cancel = false);
// Common implementation for retry // Common implementation for retry
void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time, void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time,

View File

@@ -221,8 +221,8 @@ extends = common
platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-1.zip platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-1.zip
framework = zephyr framework = zephyr
platform_packages = platform_packages =
platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-7.zip
platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.16.1-1.zip platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip
build_flags = build_flags =
${common.build_flags} ${common.build_flags}
-DUSE_ZEPHYR -DUSE_ZEPHYR

View File

@@ -1,6 +1,6 @@
pylint==3.3.8 pylint==3.3.8
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.12.9 # also change in .pre-commit-config.yaml when updating ruff==0.12.10 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating
pre-commit pre-commit

View File

@@ -0,0 +1,5 @@
substitutions:
wakeup_pin: GPIO4
<<: !include common.yaml
<<: !include common-esp32.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
wakeup_pin: GPIO4
<<: !include common.yaml
<<: !include common-esp32.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
wakeup_pin: GPIO4
<<: !include common.yaml
<<: !include common-esp32.yaml

View File

@@ -0,0 +1,3 @@
esp32_ble_tracker:
on_scan_end:
- logger.log: "Scan ended!"

View File

@@ -56,10 +56,29 @@ light:
warm_white_color_temperature: 2000 K warm_white_color_temperature: 2000 K
constant_brightness: true constant_brightness: true
effects: effects:
# Use default parameters:
- random: - random:
name: "Random Effect" # Customize parameters - use longer names to potentially trigger buffer issues
- random:
name: "My Very Slow Random Effect With Long Name"
transition_length: 30ms
update_interval: 30ms
- random:
name: "My Fast Random Effect That Changes Quickly"
transition_length: 4ms
update_interval: 5ms
- random:
name: "Random Effect With Medium Length Name Here"
transition_length: 100ms transition_length: 100ms
update_interval: 200ms update_interval: 200ms
- random:
name: "Another Random Effect With Different Parameters"
transition_length: 2ms
update_interval: 3ms
- random:
name: "Yet Another Random Effect To Test Memory"
transition_length: 15ms
update_interval: 20ms
- strobe: - strobe:
name: "Strobe Effect" name: "Strobe Effect"
- pulse: - pulse:
@@ -73,6 +92,17 @@ light:
red: test_red red: test_red
green: test_green green: test_green
blue: test_blue blue: test_blue
effects:
# Same random effects to test for cross-contamination
- random:
- random:
name: "RGB Slow Random"
transition_length: 20ms
update_interval: 25ms
- random:
name: "RGB Fast Random"
transition_length: 2ms
update_interval: 3ms
- platform: binary - platform: binary
name: "Test Binary Light" name: "Test Binary Light"

View File

@@ -0,0 +1,165 @@
esphome:
name: multi-device-preferences-test
# Define multiple devices for testing preference storage
devices:
- id: device_a
name: Device A
- id: device_b
name: Device B
host:
api: # Port will be automatically injected
logger:
level: DEBUG
# Test entities with restore modes to verify preference storage
# Switches with same name on different devices - test restore mode
switch:
- platform: template
name: Light
id: light_device_a
device_id: device_a
restore_mode: RESTORE_DEFAULT_OFF
turn_on_action:
- lambda: |-
ESP_LOGI("test", "Device A Light turned ON");
turn_off_action:
- lambda: |-
ESP_LOGI("test", "Device A Light turned OFF");
- platform: template
name: Light
id: light_device_b
device_id: device_b
restore_mode: RESTORE_DEFAULT_ON # Different default to test uniqueness
turn_on_action:
- lambda: |-
ESP_LOGI("test", "Device B Light turned ON");
turn_off_action:
- lambda: |-
ESP_LOGI("test", "Device B Light turned OFF");
- platform: template
name: Light
id: light_main
restore_mode: RESTORE_DEFAULT_OFF
turn_on_action:
- lambda: |-
ESP_LOGI("test", "Main Light turned ON");
turn_off_action:
- lambda: |-
ESP_LOGI("test", "Main Light turned OFF");
# Numbers with restore to test preference storage
number:
- platform: template
name: Setpoint
id: setpoint_device_a
device_id: device_a
min_value: 10.0
max_value: 30.0
step: 0.5
restore_value: true
initial_value: 20.0
set_action:
- lambda: |-
ESP_LOGI("test", "Device A Setpoint set to %.1f", x);
id(setpoint_device_a).state = x;
- platform: template
name: Setpoint
id: setpoint_device_b
device_id: device_b
min_value: 10.0
max_value: 30.0
step: 0.5
restore_value: true
initial_value: 25.0 # Different initial to test uniqueness
set_action:
- lambda: |-
ESP_LOGI("test", "Device B Setpoint set to %.1f", x);
id(setpoint_device_b).state = x;
- platform: template
name: Setpoint
id: setpoint_main
min_value: 10.0
max_value: 30.0
step: 0.5
restore_value: true
initial_value: 22.0
set_action:
- lambda: |-
ESP_LOGI("test", "Main Setpoint set to %.1f", x);
id(setpoint_main).state = x;
# Selects with restore to test preference storage
select:
- platform: template
name: Mode
id: mode_device_a
device_id: device_a
options:
- "Auto"
- "Manual"
- "Off"
restore_value: true
initial_option: "Auto"
set_action:
- lambda: |-
ESP_LOGI("test", "Device A Mode set to %s", x.c_str());
id(mode_device_a).state = x;
- platform: template
name: Mode
id: mode_device_b
device_id: device_b
options:
- "Auto"
- "Manual"
- "Off"
restore_value: true
initial_option: "Manual" # Different initial to test uniqueness
set_action:
- lambda: |-
ESP_LOGI("test", "Device B Mode set to %s", x.c_str());
id(mode_device_b).state = x;
- platform: template
name: Mode
id: mode_main
options:
- "Auto"
- "Manual"
- "Off"
restore_value: true
initial_option: "Off"
set_action:
- lambda: |-
ESP_LOGI("test", "Main Mode set to %s", x.c_str());
id(mode_main).state = x;
# Button to trigger preference logging test
button:
- platform: template
name: Test Preferences
on_press:
- lambda: |-
ESP_LOGI("test", "Testing preference storage uniqueness:");
ESP_LOGI("test", "Device A Light state: %s", id(light_device_a).state ? "ON" : "OFF");
ESP_LOGI("test", "Device B Light state: %s", id(light_device_b).state ? "ON" : "OFF");
ESP_LOGI("test", "Main Light state: %s", id(light_main).state ? "ON" : "OFF");
ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state);
ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state);
ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state);
ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str());
ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str());
ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str());
// Log preference hashes for entities that actually store preferences
ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash());
ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash());
ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash());
ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash());
ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash());
ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash());

View File

@@ -0,0 +1,45 @@
esphome:
name: test-parallel-delays
host:
logger:
level: DEBUG
api:
actions:
- action: test_parallel_delays
then:
# Start three parallel script instances with small delays between starts
- globals.set:
id: instance_counter
value: '1'
- script.execute: parallel_delay_script
- delay: 10ms
- globals.set:
id: instance_counter
value: '2'
- script.execute: parallel_delay_script
- delay: 10ms
- globals.set:
id: instance_counter
value: '3'
- script.execute: parallel_delay_script
globals:
- id: instance_counter
type: int
initial_value: '0'
script:
- id: parallel_delay_script
mode: parallel
then:
- lambda: !lambda |-
int instance = id(instance_counter);
ESP_LOGI("TEST", "Parallel script instance %d started", instance);
- delay: 1s
- lambda: !lambda |-
static int completed_counter = 0;
completed_counter++;
ESP_LOGI("TEST", "Parallel script instance %d completed after delay", completed_counter);

View File

@@ -89,3 +89,73 @@ async def test_delay_action_cancellation(
assert 0.4 < time_from_second_start < 0.6, ( assert 0.4 < time_from_second_start < 0.6, (
f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s" f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s"
) )
@pytest.mark.asyncio
async def test_parallel_script_delays(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that parallel scripts with delays don't interfere with each other."""
loop = asyncio.get_running_loop()
# Track script executions
script_starts: list[float] = []
script_ends: list[float] = []
# Patterns to match
start_pattern = re.compile(r"Parallel script instance \d+ started")
end_pattern = re.compile(r"Parallel script instance \d+ completed after delay")
# Future to track when all scripts have completed
all_scripts_completed = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for parallel script messages."""
current_time = loop.time()
if start_pattern.search(line):
script_starts.append(current_time)
if end_pattern.search(line):
script_ends.append(current_time)
# Check if we have all 3 completions
if len(script_ends) == 3 and not all_scripts_completed.done():
all_scripts_completed.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Get services
entities, services = await client.list_entities_services()
# Find our test service
test_service = next(
(s for s in services if s.name == "test_parallel_delays"), None
)
assert test_service is not None, "test_parallel_delays service not found"
# Execute the test - this will start 3 parallel scripts with 1 second delays
client.execute_service(test_service, {})
# Wait for all scripts to complete (should take ~1 second, not 3)
await asyncio.wait_for(all_scripts_completed, timeout=2.0)
# Verify we had 3 starts and 3 ends
assert len(script_starts) == 3, (
f"Expected 3 script starts, got {len(script_starts)}"
)
assert len(script_ends) == 3, f"Expected 3 script ends, got {len(script_ends)}"
# Verify they ran in parallel - all should complete within ~1.5 seconds
first_start = min(script_starts)
last_end = max(script_ends)
total_time = last_end - first_start
# If running in parallel, total time should be close to 1 second
# If they were interfering (running sequentially), it would take 3+ seconds
assert total_time < 1.5, (
f"Parallel scripts took {total_time:.2f}s total, should be ~1s if running in parallel"
)

View File

@@ -108,14 +108,51 @@ async def test_light_calls(
# Wait for flash to end # Wait for flash to end
state = await wait_for_state_change(rgbcw_light.key) state = await wait_for_state_change(rgbcw_light.key)
# Test 13: effect only # Test 13: effect only - test all random effects
# First ensure light is on # First ensure light is on
client.light_command(key=rgbcw_light.key, state=True) client.light_command(key=rgbcw_light.key, state=True)
state = await wait_for_state_change(rgbcw_light.key) state = await wait_for_state_change(rgbcw_light.key)
# Now set effect
client.light_command(key=rgbcw_light.key, effect="Random Effect") # Test 13a: Default random effect (no name, gets default name "Random")
client.light_command(key=rgbcw_light.key, effect="Random")
state = await wait_for_state_change(rgbcw_light.key) state = await wait_for_state_change(rgbcw_light.key)
assert state.effect == "Random Effect" assert state.effect == "Random"
# Test 13b: Slow random effect with long name
client.light_command(
key=rgbcw_light.key, effect="My Very Slow Random Effect With Long Name"
)
state = await wait_for_state_change(rgbcw_light.key)
assert state.effect == "My Very Slow Random Effect With Long Name"
# Test 13c: Fast random effect with long name
client.light_command(
key=rgbcw_light.key, effect="My Fast Random Effect That Changes Quickly"
)
state = await wait_for_state_change(rgbcw_light.key)
assert state.effect == "My Fast Random Effect That Changes Quickly"
# Test 13d: Random effect with medium length name
client.light_command(
key=rgbcw_light.key, effect="Random Effect With Medium Length Name Here"
)
state = await wait_for_state_change(rgbcw_light.key)
assert state.effect == "Random Effect With Medium Length Name Here"
# Test 13e: Another random effect
client.light_command(
key=rgbcw_light.key,
effect="Another Random Effect With Different Parameters",
)
state = await wait_for_state_change(rgbcw_light.key)
assert state.effect == "Another Random Effect With Different Parameters"
# Test 13f: Yet another random effect
client.light_command(
key=rgbcw_light.key, effect="Yet Another Random Effect To Test Memory"
)
state = await wait_for_state_change(rgbcw_light.key)
assert state.effect == "Yet Another Random Effect To Test Memory"
# Test 14: stop effect # Test 14: stop effect
client.light_command(key=rgbcw_light.key, effect="None") client.light_command(key=rgbcw_light.key, effect="None")

View File

@@ -0,0 +1,144 @@
"""Test multi-device preference storage functionality."""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_multi_device_preferences(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that entities with same names on different devices have unique preference storage."""
loop = asyncio.get_running_loop()
log_lines: list[str] = []
preferences_logged = loop.create_future()
# Patterns to match preference hash logs
switch_hash_pattern_device = re.compile(r"Device ([AB]) Switch Pref Hash: (\d+)")
switch_hash_pattern_main = re.compile(r"Main Switch Pref Hash: (\d+)")
number_hash_pattern_device = re.compile(r"Device ([AB]) Number Pref Hash: (\d+)")
number_hash_pattern_main = re.compile(r"Main Number Pref Hash: (\d+)")
switch_hashes: dict[str, int] = {}
number_hashes: dict[str, int] = {}
def check_output(line: str) -> None:
"""Check log output for preference hash information."""
log_lines.append(line)
# Look for device switch preference hash logs
match = switch_hash_pattern_device.search(line)
if match:
device = match.group(1)
hash_value = int(match.group(2))
switch_hashes[device] = hash_value
# Look for main switch preference hash
match = switch_hash_pattern_main.search(line)
if match:
hash_value = int(match.group(1))
switch_hashes["Main"] = hash_value
# Look for device number preference hash logs
match = number_hash_pattern_device.search(line)
if match:
device = match.group(1)
hash_value = int(match.group(2))
number_hashes[device] = hash_value
# Look for main number preference hash
match = number_hash_pattern_main.search(line)
if match:
hash_value = int(match.group(1))
number_hashes["Main"] = hash_value
# If we have all hashes, complete the future
if (
len(switch_hashes) == 3
and len(number_hashes) == 3
and not preferences_logged.done()
):
preferences_logged.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Get entity list
entities, _ = await client.list_entities_services()
# Verify we have the expected entities with duplicate names on different devices
# Check switches (3 with name "Light")
switches = [
e for e in entities if isinstance(e, SwitchInfo) and e.name == "Light"
]
assert len(switches) == 3, f"Expected 3 'Light' switches, got {len(switches)}"
# Check numbers (3 with name "Setpoint")
numbers = [
e for e in entities if isinstance(e, NumberInfo) and e.name == "Setpoint"
]
assert len(numbers) == 3, f"Expected 3 'Setpoint' numbers, got {len(numbers)}"
# Check selects (3 with name "Mode")
selects = [
e for e in entities if isinstance(e, SelectInfo) and e.name == "Mode"
]
assert len(selects) == 3, f"Expected 3 'Mode' selects, got {len(selects)}"
# Find the test button entity to trigger preference logging
buttons = [e for e in entities if isinstance(e, ButtonInfo)]
test_button = next((b for b in buttons if b.name == "Test Preferences"), None)
assert test_button is not None, "Test Preferences button not found"
# Press the button to trigger logging
client.button_command(test_button.key)
# Wait for preference hashes to be logged
try:
await asyncio.wait_for(preferences_logged, timeout=5.0)
except TimeoutError:
pytest.fail("Preference hashes not logged within timeout")
# Verify all switch preference hashes are unique
assert len(switch_hashes) == 3, (
f"Expected 3 devices with switches, got {switch_hashes}"
)
switch_hash_values = list(switch_hashes.values())
assert len(switch_hash_values) == len(set(switch_hash_values)), (
f"Switch preference hashes are not unique: {switch_hashes}"
)
# Verify all number preference hashes are unique
assert len(number_hashes) == 3, (
f"Expected 3 devices with numbers, got {number_hashes}"
)
number_hash_values = list(number_hashes.values())
assert len(number_hash_values) == len(set(number_hash_values)), (
f"Number preference hashes are not unique: {number_hashes}"
)
# Verify Device A and Device B have different hashes (they have device_id set)
assert switch_hashes["A"] != switch_hashes["B"], (
f"Device A and B switches should have different hashes: A={switch_hashes['A']}, B={switch_hashes['B']}"
)
assert number_hashes["A"] != number_hashes["B"], (
f"Device A and B numbers should have different hashes: A={number_hashes['A']}, B={number_hashes['B']}"
)
# Verify Main device hash is different from both A and B
assert switch_hashes["Main"] != switch_hashes["A"], (
f"Main and Device A switches should have different hashes: Main={switch_hashes['Main']}, A={switch_hashes['A']}"
)
assert switch_hashes["Main"] != switch_hashes["B"], (
f"Main and Device B switches should have different hashes: Main={switch_hashes['Main']}, B={switch_hashes['B']}"
)

View File

@@ -705,3 +705,48 @@ def test_empty_or_null_device_id_on_entity() -> None:
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None}
validated2 = validator(config2) validated2 = validator(config2)
assert validated2 == config2 assert validated2 == config2
def test_entity_duplicate_validator_non_ascii_names() -> None:
"""Test that non-ASCII names show helpful error messages."""
# Create validator for binary_sensor platform
validator = entity_duplicate_validator("binary_sensor")
# First Russian sensor should pass
config1 = {CONF_NAME: "Датчик открытия основного крана"}
validated1 = validator(config1)
assert validated1 == config1
# Second Russian sensor with different text but same ASCII conversion should fail
config2 = {CONF_NAME: "Датчик закрытия основного крана"}
with pytest.raises(
Invalid,
match=re.compile(
r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*"
r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*"
r"Both convert to ASCII ID: '_______________________________'.*"
r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)",
re.DOTALL,
),
):
validator(config2)
def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
"""Test that identical names don't show the enhanced message."""
# Create validator for sensor platform
validator = entity_duplicate_validator("sensor")
# First entity should pass
config1 = {CONF_NAME: "Temperature"}
validated1 = validator(config1)
assert validated1 == config1
# Second entity with exact same name should fail without enhanced message
config2 = {CONF_NAME: "Temperature"}
with pytest.raises(
Invalid,
match=r"Duplicate sensor entity with name 'Temperature' found.*"
r"Each entity on a device must have a unique name within its platform\.$",
):
validator(config2)