Compare commits

...

23 Commits

Author SHA1 Message Date
Jesse Hills 6b61edce92 Merge pull request #14062 from esphome/bump-2026.2.0
2026.2.0
2026-02-19 11:36:00 +13:00
Jesse Hills 2c89cded4b Bump version to 2026.2.0 2026-02-19 09:30:04 +13:00
Jesse Hills 896dc4d34d Merge pull request #14056 from esphome/bump-2026.2.0b5
2026.2.0b5
2026-02-19 09:04:47 +13:00
Jesse Hills ab572c2882 Bump version to 2026.2.0b5 2026-02-19 08:03:44 +13:00
Jonathan Swoboda 6b8264fcaa [external_components] Clean up incomplete clone on failed ref fetch (#14051)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:03:43 +13:00
Jesse Hills 973656191b Merge pull request #14038 from esphome/bump-2026.2.0b4
2026.2.0b4
2026-02-18 13:28:37 +13:00
Jesse Hills d9f493ab7a Bump version to 2026.2.0b4 2026-02-18 10:13:41 +13:00
schrob a0c4fa6496 [openthread] Fix compiler format warning (#14030) 2026-02-18 10:13:41 +13:00
Jesse Hills fd43bd2b7e Merge pull request #14025 from esphome/bump-2026.2.0b3
2026.2.0b3
2026-02-17 16:22:12 +13:00
Jesse Hills 5904808804 Bump version to 2026.2.0b3 2026-02-17 13:24:35 +13:00
Jonathan Swoboda e945e9b659 [esp32_rmt] Handle ESP32 variants without RMT hardware (#14001)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:24:35 +13:00
Jonathan Swoboda df29cdbf17 [fan] Fix preset_mode not restored on boot (#14002)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:24:35 +13:00
Jonathan Swoboda f6362aa8da [combination] Fix 'coeffecient' typo with backward-compatible deprecation (#14004)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:24:35 +13:00
J. Nick Koston 1517b7799a [wifi] Fix ESP8266 DHCP state corruption from premature dhcp_renew() (#13983)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:24:35 +13:00
Jesse Hills afa4047089 Merge pull request #13984 from esphome/bump-2026.2.0b2
2026.2.0b2
2026-02-14 16:02:49 +13:00
Jesse Hills a8a324cbfb Bump version to 2026.2.0b2 2026-02-14 13:53:54 +13:00
J. Nick Koston f6aeef2e68 [api] Fix ESP8266 noise API handshake deadlock and prompt socket cleanup (#13972) 2026-02-14 13:53:54 +13:00
Jonathan Swoboda 297dfb0db4 [docker] Suppress git detached HEAD advice (#13962)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:53:54 +13:00
Jonathan Swoboda c08356b0c1 [alarm_control_panel] Fix flaky integration test race condition (#13964)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:53:54 +13:00
Lukáš Maňas e9bf9bc691 [pulse_meter] Fix early edge detection (#12360)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-02-14 13:53:54 +13:00
J. Nick Koston ead7937dbf [api] Extract cold code from APIServer::loop() hot path (#13902) 2026-02-14 13:53:54 +13:00
J. Nick Koston 844210519a [uart] Remove redundant mutex, fix flush race, conditional event queue (#13955) 2026-02-14 13:53:54 +13:00
Guillermo Ruffino 7c70b2e04e [schema-gen] fix Windows: ensure UTF-8 encoding when reading component files (#13952) 2026-02-14 13:53:54 +13:00
41 changed files with 605 additions and 304 deletions
+1 -1
View File
@@ -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 = 2026.2.0b1
PROJECT_NUMBER = 2026.2.0
# 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
+2 -1
View File
@@ -9,7 +9,8 @@ FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS b
ARG BUILD_TYPE
FROM base-source-${BUILD_TYPE} AS base
RUN git config --system --add safe.directory "*"
RUN git config --system --add safe.directory "*" \
&& git config --system advice.detachedHead false
# Install build tools for Python packages that require compilation
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
@@ -138,10 +138,12 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
// During handshake phase, process as many actions as possible until we can't progress
// socket_->ready() stays true until next main loop, but state_action() will return
// WOULD_BLOCK when no more data is available to read
while (state_ != State::DATA && this->socket_->ready()) {
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
// that must follow reads, deadlocking the handshake. state_action() will return
// WOULD_BLOCK when no more data is available to read.
bool socket_ready = this->socket_->ready();
while (state_ != State::DATA && socket_ready) {
APIError err = state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
+73 -61
View File
@@ -117,37 +117,7 @@ void APIServer::setup() {
void APIServer::loop() {
// Accept new clients only if the socket exists and has incoming connections
if (this->socket_ && this->socket_->ready()) {
while (true) {
struct sockaddr_storage source_addr;
socklen_t addr_len = sizeof(source_addr);
auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len);
if (!sock)
break;
char peername[socket::SOCKADDR_STR_LEN];
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->clients_.size() >= this->max_connections_) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
continue;
}
ESP_LOGD(TAG, "Accept %s", peername);
auto *conn = new APIConnection(std::move(sock), this);
this->clients_.emplace_back(conn);
conn->start();
// First client connected - clear warning and update timestamp
if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
}
this->accept_new_connections_();
}
if (this->clients_.empty()) {
@@ -178,46 +148,88 @@ void APIServer::loop() {
while (client_index < this->clients_.size()) {
auto &client = this->clients_[client_index];
// Common case: process active client
if (!client->flags_.remove) {
// Common case: process active client
client->loop();
}
// Handle disconnection promptly - close socket to free LWIP PCB
// resources and prevent retransmit crashes on ESP8266.
if (client->flags_.remove) {
// Rare case: handle disconnection (don't increment - swapped element needs processing)
this->remove_client_(client_index);
} else {
client_index++;
}
}
}
void APIServer::remove_client_(size_t client_index) {
auto &client = this->clients_[client_index];
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
this->unregister_active_action_calls_for_connection(client.get());
#endif
ESP_LOGV(TAG, "Remove connection %s", client->get_name());
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Save client info before closing socket and removal for the trigger
char peername_buf[socket::SOCKADDR_STR_LEN];
std::string client_name(client->get_name());
std::string client_peername(client->get_peername_to(peername_buf));
#endif
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
// Swap with the last element and pop (avoids expensive vector shifts)
if (client_index < this->clients_.size() - 1) {
std::swap(this->clients_[client_index], this->clients_.back());
}
this->clients_.pop_back();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->clients_.empty() && this->reboot_timeout_ != 0) {
this->status_set_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Fire trigger after client is removed so api.connected reflects the true state
this->client_disconnected_trigger_.trigger(client_name, client_peername);
#endif
}
void APIServer::accept_new_connections_() {
while (true) {
struct sockaddr_storage source_addr;
socklen_t addr_len = sizeof(source_addr);
auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len);
if (!sock)
break;
char peername[socket::SOCKADDR_STR_LEN];
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->clients_.size() >= this->max_connections_) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
continue;
}
// Rare case: handle disconnection
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
this->unregister_active_action_calls_for_connection(client.get());
#endif
ESP_LOGV(TAG, "Remove connection %s", client->get_name());
ESP_LOGD(TAG, "Accept %s", peername);
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Save client info before closing socket and removal for the trigger
char peername_buf[socket::SOCKADDR_STR_LEN];
std::string client_name(client->get_name());
std::string client_peername(client->get_peername_to(peername_buf));
#endif
auto *conn = new APIConnection(std::move(sock), this);
this->clients_.emplace_back(conn);
conn->start();
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
// Swap with the last element and pop (avoids expensive vector shifts)
if (client_index < this->clients_.size() - 1) {
std::swap(this->clients_[client_index], this->clients_.back());
}
this->clients_.pop_back();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->clients_.empty() && this->reboot_timeout_ != 0) {
this->status_set_warning();
// First client connected - clear warning and update timestamp
if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Fire trigger after client is removed so api.connected reflects the true state
this->client_disconnected_trigger_.trigger(client_name, client_peername);
#endif
// Don't increment client_index since we need to process the swapped element
}
}
+5
View File
@@ -234,6 +234,11 @@ class APIServer : public Component,
#endif
protected:
// Accept incoming socket connections. Only called when socket has pending connections.
void __attribute__((noinline)) accept_new_connections_();
// Remove a disconnected client by index. Swaps with last element and pops.
void __attribute__((noinline)) remove_client_(size_t client_index);
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
const psk_t &active_psk, bool make_active);
@@ -126,7 +126,7 @@ void LinearCombinationComponent::setup() {
}
void LinearCombinationComponent::handle_new_value(float value) {
// Multiplies each sensor state by a configured coeffecient and then sums
// Multiplies each sensor state by a configured coefficient and then sums
if (!std::isfinite(value))
return;
+37 -9
View File
@@ -1,3 +1,5 @@
import logging
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
@@ -15,6 +17,8 @@ from esphome.const import (
)
from esphome.core.entity_helpers import inherit_property_from
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
combination_ns = cg.esphome_ns.namespace("combination")
@@ -47,7 +51,8 @@ SumCombinationComponent = combination_ns.class_(
"SumCombinationComponent", cg.Component, sensor.Sensor
)
CONF_COEFFECIENT = "coeffecient"
CONF_COEFFICIENT = "coefficient"
CONF_COEFFECIENT = "coeffecient" # Deprecated, remove before 2026.12.0
CONF_ERROR = "error"
CONF_KALMAN = "kalman"
CONF_LINEAR = "linear"
@@ -68,11 +73,34 @@ KALMAN_SOURCE_SCHEMA = cv.Schema(
}
)
LINEAR_SOURCE_SCHEMA = cv.Schema(
{
cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor),
cv.Required(CONF_COEFFECIENT): cv.templatable(cv.float_),
}
def _migrate_coeffecient(config):
"""Migrate deprecated 'coeffecient' spelling to 'coefficient'."""
if CONF_COEFFECIENT in config:
if CONF_COEFFICIENT in config:
raise cv.Invalid(
f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'"
)
_LOGGER.warning(
"'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0",
CONF_COEFFECIENT,
CONF_COEFFICIENT,
)
config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT)
elif CONF_COEFFICIENT not in config:
raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option")
return config
LINEAR_SOURCE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor),
cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_),
cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_),
}
),
_migrate_coeffecient,
)
SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema(
@@ -162,12 +190,12 @@ async def to_code(config):
)
cg.add(var.add_source(source, error))
elif config[CONF_TYPE] == CONF_LINEAR:
coeffecient = await cg.templatable(
source_conf[CONF_COEFFECIENT],
coefficient = await cg.templatable(
source_conf[CONF_COEFFICIENT],
[(float, "x")],
cg.float_,
)
cg.add(var.add_source(source, coeffecient))
cg.add(var.add_source(source, coefficient))
else:
cg.add(var.add_source(source))
+2 -2
View File
@@ -44,9 +44,9 @@ from esphome.const import (
from esphome.core import CORE, HexInt, TimePeriod
from esphome.coroutine import CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, write_file_if_changed
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.types import ConfigType
from esphome.writer import clean_cmake_cache, rmtree
from esphome.writer import clean_cmake_cache
from .boards import BOARDS, STANDARD_BOARDS
from .const import ( # noqa
+22
View File
@@ -1,8 +1,30 @@
from esphome.components import esp32
import esphome.config_validation as cv
from esphome.core import CORE
CODEOWNERS = ["@jesserockz"]
VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61}
def validate_rmt_not_supported(rmt_only_keys):
"""Validate that RMT-only config keys are not used on variants without RMT hardware."""
rmt_only_keys = set(rmt_only_keys)
def _validator(config):
if CORE.is_esp32:
variant = esp32.get_esp32_variant()
if variant in VARIANTS_NO_RMT:
unsupported = rmt_only_keys.intersection(config)
if unsupported:
keys = ", ".join(sorted(f"'{k}'" for k in unsupported))
raise cv.Invalid(
f"{keys} not available on {variant} (no RMT hardware)"
)
return config
return _validator
def validate_clock_resolution():
def _validator(value):
@@ -3,7 +3,7 @@ import logging
from esphome import pins
import esphome.codegen as cg
from esphome.components import esp32, light
from esphome.components import esp32, esp32_rmt, light
from esphome.components.const import CONF_USE_PSRAM
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
@@ -71,6 +71,10 @@ CONF_RESET_LOW = "reset_low"
CONFIG_SCHEMA = cv.All(
esp32.only_on_variant(
unsupported=list(esp32_rmt.VARIANTS_NO_RMT),
msg_prefix="ESP32 RMT LED strip",
),
light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput),
+7 -1
View File
@@ -221,12 +221,17 @@ void Fan::publish_state() {
}
// Random 32-bit value, change this every time the layout of the FanRestoreState struct changes.
constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABA;
constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABB;
optional<FanRestoreState> Fan::restore_state_() {
FanRestoreState recovered{};
this->rtc_ = this->make_entity_preference<FanRestoreState>(RESTORE_STATE_VERSION);
bool restored = this->rtc_.load(&recovered);
if (!restored) {
// No valid saved data; ensure preset_mode sentinel is set
recovered.preset_mode = FanRestoreState::NO_PRESET;
}
switch (this->restore_mode_) {
case FanRestoreMode::NO_RESTORE:
return {};
@@ -264,6 +269,7 @@ void Fan::save_state_() {
state.oscillating = this->oscillating;
state.speed = this->speed;
state.direction = this->direction;
state.preset_mode = FanRestoreState::NO_PRESET;
if (this->has_preset_mode()) {
const auto &preset_modes = traits.supported_preset_modes();
+3 -1
View File
@@ -91,11 +91,13 @@ class FanCall {
};
struct FanRestoreState {
static constexpr uint8_t NO_PRESET = UINT8_MAX;
bool state;
int speed;
bool oscillating;
FanDirection direction;
uint8_t preset_mode;
uint8_t preset_mode{NO_PRESET};
/// Convert this struct to a fan call that can be performed.
FanCall to_call(Fan &fan);
@@ -28,15 +28,15 @@ fan::FanCall HBridgeFan::brake() {
}
void HBridgeFan::setup() {
// Construct traits before restore so preset modes can be looked up by index
this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, true, this->speed_count_);
this->traits_.set_supported_preset_modes(this->preset_modes_);
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(*this);
this->write_state_();
}
// Construct traits
this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, true, this->speed_count_);
this->traits_.set_supported_preset_modes(this->preset_modes_);
}
void HBridgeFan::dump_config() {
@@ -115,7 +115,7 @@ void OpenThreadComponent::ot_main() {
ESP_LOGE(TAG, "Failed to set OpenThread pollperiod.");
}
uint32_t link_polling_period = otLinkGetPollPeriod(esp_openthread_get_instance());
ESP_LOGD(TAG, "Link Polling Period: %d", link_polling_period);
ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, link_polling_period);
}
link_mode_config.mRxOnWhenIdle = this->poll_period == 0;
link_mode_config.mDeviceType = false;
@@ -38,8 +38,7 @@ void PulseMeterSensor::setup() {
}
void PulseMeterSensor::loop() {
// Reset the count in get before we pass it back to the ISR as set
this->get_->count_ = 0;
State state;
{
// Lock the interrupt so the interrupt code doesn't interfere with itself
@@ -58,31 +57,35 @@ void PulseMeterSensor::loop() {
}
this->last_pin_val_ = current;
// Swap out set and get to get the latest state from the ISR
std::swap(this->set_, this->get_);
// Get the latest state from the ISR and reset the count in the ISR
state.last_detected_edge_us_ = this->state_.last_detected_edge_us_;
state.last_rising_edge_us_ = this->state_.last_rising_edge_us_;
state.count_ = this->state_.count_;
this->state_.count_ = 0;
}
const uint32_t now = micros();
// If an edge was peeked, repay the debt
if (this->peeked_edge_ && this->get_->count_ > 0) {
if (this->peeked_edge_ && state.count_ > 0) {
this->peeked_edge_ = false;
this->get_->count_--; // NOLINT(clang-diagnostic-deprecated-volatile)
state.count_--;
}
// If there is an unprocessed edge, and filter_us_ has passed since, count this edge early
if (this->get_->last_rising_edge_us_ != this->get_->last_detected_edge_us_ &&
now - this->get_->last_rising_edge_us_ >= this->filter_us_) {
// If there is an unprocessed edge, and filter_us_ has passed since, count this edge early.
// Wait for the debt to be repaid before counting another unprocessed edge early.
if (!this->peeked_edge_ && state.last_rising_edge_us_ != state.last_detected_edge_us_ &&
now - state.last_rising_edge_us_ >= this->filter_us_) {
this->peeked_edge_ = true;
this->get_->last_detected_edge_us_ = this->get_->last_rising_edge_us_;
this->get_->count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
state.last_detected_edge_us_ = state.last_rising_edge_us_;
state.count_++;
}
// Check if we detected a pulse this loop
if (this->get_->count_ > 0) {
if (state.count_ > 0) {
// Keep a running total of pulses if a total sensor is configured
if (this->total_sensor_ != nullptr) {
this->total_pulses_ += this->get_->count_;
this->total_pulses_ += state.count_;
const uint32_t total = this->total_pulses_;
this->total_sensor_->publish_state(total);
}
@@ -94,15 +97,15 @@ void PulseMeterSensor::loop() {
this->meter_state_ = MeterState::RUNNING;
} break;
case MeterState::RUNNING: {
uint32_t delta_us = this->get_->last_detected_edge_us_ - this->last_processed_edge_us_;
float pulse_width_us = delta_us / float(this->get_->count_);
ESP_LOGV(TAG, "New pulse, delta: %" PRIu32 " µs, count: %" PRIu32 ", width: %.5f µs", delta_us,
this->get_->count_, pulse_width_us);
uint32_t delta_us = state.last_detected_edge_us_ - this->last_processed_edge_us_;
float pulse_width_us = delta_us / float(state.count_);
ESP_LOGV(TAG, "New pulse, delta: %" PRIu32 " µs, count: %" PRIu32 ", width: %.5f µs", delta_us, state.count_,
pulse_width_us);
this->publish_state((60.0f * 1000000.0f) / pulse_width_us);
} break;
}
this->last_processed_edge_us_ = this->get_->last_detected_edge_us_;
this->last_processed_edge_us_ = state.last_detected_edge_us_;
}
// No detected edges this loop
else {
@@ -141,14 +144,14 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) {
// This is an interrupt handler - we can't call any virtual method from this method
// Get the current time before we do anything else so the measurements are consistent
const uint32_t now = micros();
auto &state = sensor->edge_state_;
auto &set = *sensor->set_;
auto &edge_state = sensor->edge_state_;
auto &state = sensor->state_;
if ((now - state.last_sent_edge_us_) >= sensor->filter_us_) {
state.last_sent_edge_us_ = now;
set.last_detected_edge_us_ = now;
set.last_rising_edge_us_ = now;
set.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
if ((now - edge_state.last_sent_edge_us_) >= sensor->filter_us_) {
edge_state.last_sent_edge_us_ = now;
state.last_detected_edge_us_ = now;
state.last_rising_edge_us_ = now;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
}
// This ISR is bound to rising edges, so the pin is high
@@ -160,26 +163,26 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) {
// Get the current time before we do anything else so the measurements are consistent
const uint32_t now = micros();
const bool pin_val = sensor->isr_pin_.digital_read();
auto &state = sensor->pulse_state_;
auto &set = *sensor->set_;
auto &pulse_state = sensor->pulse_state_;
auto &state = sensor->state_;
// Filter length has passed since the last interrupt
const bool length = now - state.last_intr_ >= sensor->filter_us_;
const bool length = now - pulse_state.last_intr_ >= sensor->filter_us_;
if (length && state.latched_ && !sensor->last_pin_val_) { // Long enough low edge
state.latched_ = false;
} else if (length && !state.latched_ && sensor->last_pin_val_) { // Long enough high edge
state.latched_ = true;
set.last_detected_edge_us_ = state.last_intr_;
set.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
if (length && pulse_state.latched_ && !sensor->last_pin_val_) { // Long enough low edge
pulse_state.latched_ = false;
} else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge
pulse_state.latched_ = true;
state.last_detected_edge_us_ = pulse_state.last_intr_;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
}
// Due to order of operations this includes
// length && latched && rising (just reset from a long low edge)
// !latched && (rising || high) (noise on the line resetting the potential rising edge)
set.last_rising_edge_us_ = !state.latched_ && pin_val ? now : set.last_detected_edge_us_;
state.last_rising_edge_us_ = !pulse_state.latched_ && pin_val ? now : state.last_detected_edge_us_;
state.last_intr_ = now;
pulse_state.last_intr_ = now;
sensor->last_pin_val_ = pin_val;
}
@@ -46,17 +46,16 @@ class PulseMeterSensor : public sensor::Sensor, public Component {
uint32_t total_pulses_ = 0;
uint32_t last_processed_edge_us_ = 0;
// This struct (and the two pointers) are used to pass data between the ISR and loop.
// These two pointers are exchanged each loop.
// Use these to send data from the ISR to the loop not the other way around (except for resetting the values).
// This struct and variable are used to pass data between the ISR and loop.
// The data from state_ is read and then count_ in state_ is reset in each loop.
// This must be done while guarded by an InterruptLock. Use this variable to send data
// from the ISR to the loop not the other way around (except for resetting count_).
struct State {
uint32_t last_detected_edge_us_ = 0;
uint32_t last_rising_edge_us_ = 0;
uint32_t count_ = 0;
};
State state_[2];
volatile State *set_ = state_;
volatile State *get_ = state_ + 1;
volatile State state_{};
// Only use the following variables in the ISR or while guarded by an InterruptLock
ISRInternalGPIOPin isr_pin_;
+4 -1
View File
@@ -119,6 +119,8 @@ class RemoteComponentBase {
};
#ifdef USE_ESP32
#include <soc/soc_caps.h>
#if SOC_RMT_SUPPORTED
class RemoteRMTChannel {
public:
void set_clock_resolution(uint32_t clock_resolution) { this->clock_resolution_ = clock_resolution; }
@@ -137,7 +139,8 @@ class RemoteRMTChannel {
uint32_t clock_resolution_{1000000};
uint32_t rmt_symbols_;
};
#endif
#endif // SOC_RMT_SUPPORTED
#endif // USE_ESP32
class RemoteTransmitterBase : public RemoteComponentBase {
public:
+36 -5
View File
@@ -65,6 +65,8 @@ RemoteReceiverComponent = remote_receiver_ns.class_(
def validate_config(config):
if CORE.is_esp32:
variant = esp32.get_esp32_variant()
if variant in esp32_rmt.VARIANTS_NO_RMT:
return config
if variant in (esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S2):
max_idle = 65535
else:
@@ -110,6 +112,8 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
cv.SplitDefault(
CONF_BUFFER_SIZE,
esp32="10000b",
esp32_c2="1000b",
esp32_c61="1000b",
esp8266="1000b",
bk72xx="1000b",
ln882x="1000b",
@@ -131,9 +135,11 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
cv.SplitDefault(
CONF_RMT_SYMBOLS,
esp32=192,
esp32_c2=cv.UNDEFINED,
esp32_c3=96,
esp32_c5=96,
esp32_c6=96,
esp32_c61=cv.UNDEFINED,
esp32_h2=96,
esp32_p4=192,
esp32_s2=192,
@@ -145,6 +151,8 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
cv.SplitDefault(
CONF_RECEIVE_SYMBOLS,
esp32=192,
esp32_c2=cv.UNDEFINED,
esp32_c61=cv.UNDEFINED,
): cv.All(cv.only_on_esp32, cv.int_range(min=2)),
cv.Optional(CONF_USE_DMA): cv.All(
esp32.only_on_variant(
@@ -152,24 +160,45 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
),
cv.boolean,
),
cv.SplitDefault(CONF_CARRIER_DUTY_PERCENT, esp32=100): cv.All(
cv.SplitDefault(
CONF_CARRIER_DUTY_PERCENT,
esp32=100,
esp32_c2=cv.UNDEFINED,
esp32_c61=cv.UNDEFINED,
): cv.All(
cv.only_on_esp32,
cv.percentage_int,
cv.Range(min=1, max=100),
),
cv.SplitDefault(CONF_CARRIER_FREQUENCY, esp32="0Hz"): cv.All(
cv.only_on_esp32, cv.frequency, cv.int_
),
cv.SplitDefault(
CONF_CARRIER_FREQUENCY,
esp32="0Hz",
esp32_c2=cv.UNDEFINED,
esp32_c61=cv.UNDEFINED,
): cv.All(cv.only_on_esp32, cv.frequency, cv.int_),
}
)
.extend(cv.COMPONENT_SCHEMA)
.add_extra(
esp32_rmt.validate_rmt_not_supported(
[
CONF_CLOCK_RESOLUTION,
CONF_USE_DMA,
CONF_RMT_SYMBOLS,
CONF_FILTER_SYMBOLS,
CONF_RECEIVE_SYMBOLS,
CONF_CARRIER_DUTY_PERCENT,
CONF_CARRIER_FREQUENCY,
]
)
)
.add_extra(validate_config)
)
async def to_code(config):
pin = await cg.gpio_pin_expression(config[CONF_PIN])
if CORE.is_esp32:
if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT:
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
esp32.include_builtin_idf_component("esp_driver_rmt")
@@ -213,6 +242,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_IDF,
},
"remote_receiver.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
PlatformFramework.ESP8266_ARDUINO,
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.RTL87XX_ARDUINO,
@@ -3,7 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040)
#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
namespace esphome::remote_receiver {
@@ -6,12 +6,15 @@
#include <cinttypes>
#if defined(USE_ESP32)
#include <soc/soc_caps.h>
#if SOC_RMT_SUPPORTED
#include <driver/rmt_rx.h>
#endif
#endif // SOC_RMT_SUPPORTED
#endif // USE_ESP32
namespace esphome::remote_receiver {
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040)
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
struct RemoteReceiverComponentStore {
static void gpio_intr(RemoteReceiverComponentStore *arg);
@@ -35,7 +38,7 @@ struct RemoteReceiverComponentStore {
volatile bool prev_level{false};
volatile bool overflow{false};
};
#elif defined(USE_ESP32)
#elif defined(USE_ESP32) && SOC_RMT_SUPPORTED
struct RemoteReceiverComponentStore {
/// Stores RMT symbols and rx done event data
volatile uint8_t *buffer{nullptr};
@@ -54,7 +57,7 @@ struct RemoteReceiverComponentStore {
class RemoteReceiverComponent : public remote_base::RemoteReceiverBase,
public Component
#ifdef USE_ESP32
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
,
public remote_base::RemoteRMTChannel
#endif
@@ -66,7 +69,7 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase,
void dump_config() override;
void loop() override;
#ifdef USE_ESP32
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void set_filter_symbols(uint32_t filter_symbols) { this->filter_symbols_ = filter_symbols; }
void set_receive_symbols(uint32_t receive_symbols) { this->receive_symbols_ = receive_symbols; }
void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; }
@@ -78,7 +81,7 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase,
void set_idle_us(uint32_t idle_us) { this->idle_us_ = idle_us; }
protected:
#ifdef USE_ESP32
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void decode_rmt_(rmt_symbol_word_t *item, size_t item_count);
rmt_channel_handle_t channel_{NULL};
uint32_t filter_symbols_{0};
@@ -94,7 +97,7 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase,
RemoteReceiverComponentStore store_;
#endif
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040)
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
HighFrequencyLoopRequester high_freq_;
#endif
@@ -2,6 +2,8 @@
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include <soc/soc_caps.h>
#if SOC_RMT_SUPPORTED
#include <driver/gpio.h>
#include <esp_clk_tree.h>
@@ -248,4 +250,5 @@ void RemoteReceiverComponent::decode_rmt_(rmt_symbol_word_t *item, size_t item_c
} // namespace esphome::remote_receiver
#endif
#endif // SOC_RMT_SUPPORTED
#endif // USE_ESP32
@@ -40,45 +40,66 @@ DigitalWriteAction = remote_transmitter_ns.class_(
cg.Parented.template(RemoteTransmitterComponent),
)
MULTI_CONF = True
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(RemoteTransmitterComponent),
cv.Required(CONF_PIN): pins.gpio_output_pin_schema,
cv.Required(CONF_CARRIER_DUTY_PERCENT): cv.All(
cv.percentage_int, cv.Range(min=1, max=100)
),
cv.Optional(CONF_CLOCK_RESOLUTION): cv.All(
cv.only_on_esp32,
esp32_rmt.validate_clock_resolution(),
),
cv.Optional(CONF_EOT_LEVEL): cv.All(cv.only_on_esp32, cv.boolean),
cv.Optional(CONF_USE_DMA): cv.All(
esp32.only_on_variant(
supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3]
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(RemoteTransmitterComponent),
cv.Required(CONF_PIN): pins.gpio_output_pin_schema,
cv.Required(CONF_CARRIER_DUTY_PERCENT): cv.All(
cv.percentage_int, cv.Range(min=1, max=100)
),
cv.boolean,
),
cv.SplitDefault(
CONF_RMT_SYMBOLS,
esp32=64,
esp32_c3=48,
esp32_c5=48,
esp32_c6=48,
esp32_h2=48,
esp32_p4=48,
esp32_s2=64,
esp32_s3=48,
): cv.All(cv.only_on_esp32, cv.int_range(min=2)),
cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean),
cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True),
}
).extend(cv.COMPONENT_SCHEMA)
cv.Optional(CONF_CLOCK_RESOLUTION): cv.All(
cv.only_on_esp32,
esp32_rmt.validate_clock_resolution(),
),
cv.Optional(CONF_EOT_LEVEL): cv.All(cv.only_on_esp32, cv.boolean),
cv.Optional(CONF_USE_DMA): cv.All(
esp32.only_on_variant(
supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3]
),
cv.boolean,
),
cv.SplitDefault(
CONF_RMT_SYMBOLS,
esp32=64,
esp32_c2=cv.UNDEFINED,
esp32_c3=48,
esp32_c5=48,
esp32_c6=48,
esp32_c61=cv.UNDEFINED,
esp32_h2=48,
esp32_p4=48,
esp32_s2=64,
esp32_s3=48,
): cv.All(cv.only_on_esp32, cv.int_range(min=2)),
cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean),
cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True),
}
)
.extend(cv.COMPONENT_SCHEMA)
.add_extra(
esp32_rmt.validate_rmt_not_supported(
[
CONF_CLOCK_RESOLUTION,
CONF_EOT_LEVEL,
CONF_USE_DMA,
CONF_RMT_SYMBOLS,
CONF_NON_BLOCKING,
]
)
)
)
def _validate_non_blocking(config):
if CORE.is_esp32 and CONF_NON_BLOCKING not in config:
if (
CORE.is_esp32
and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT
and CONF_NON_BLOCKING not in config
):
_LOGGER.warning(
"'non_blocking' is not set for 'remote_transmitter' and will default to 'true'.\n"
"The default behavior changed in 2025.11.0; previously blocking mode was used.\n"
@@ -111,7 +132,7 @@ async def digital_write_action_to_code(config, action_id, template_arg, args):
async def to_code(config):
pin = await cg.gpio_pin_expression(config[CONF_PIN])
if CORE.is_esp32:
if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT:
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
esp32.include_builtin_idf_component("esp_driver_rmt")
@@ -155,6 +176,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_IDF,
},
"remote_transmitter.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
PlatformFramework.ESP8266_ARDUINO,
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.RTL87XX_ARDUINO,
@@ -5,8 +5,7 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace remote_transmitter {
namespace esphome::remote_transmitter {
template<typename... Ts> class DigitalWriteAction : public Action<Ts...>, public Parented<RemoteTransmitterComponent> {
public:
@@ -14,5 +13,4 @@ template<typename... Ts> class DigitalWriteAction : public Action<Ts...>, public
void play(const Ts &...x) override { this->parent_->digital_write(this->value_.value(x...)); }
};
} // namespace remote_transmitter
} // namespace esphome
} // namespace esphome::remote_transmitter
@@ -2,10 +2,9 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040)
#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
namespace esphome {
namespace remote_transmitter {
namespace esphome::remote_transmitter {
static const char *const TAG = "remote_transmitter";
@@ -105,7 +104,6 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
this->complete_trigger_.trigger();
}
} // namespace remote_transmitter
} // namespace esphome
} // namespace esphome::remote_transmitter
#endif
@@ -6,13 +6,15 @@
#include <vector>
#if defined(USE_ESP32)
#include <soc/soc_caps.h>
#if SOC_RMT_SUPPORTED
#include <driver/rmt_tx.h>
#endif
#endif // SOC_RMT_SUPPORTED
#endif // USE_ESP32
namespace esphome {
namespace remote_transmitter {
namespace esphome::remote_transmitter {
#ifdef USE_ESP32
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1)
// IDF version 5.5.1 and above is required because of a bug in
// the RMT encoder: https://github.com/espressif/esp-idf/issues/17244
@@ -33,7 +35,7 @@ struct RemoteTransmitterComponentStore {
class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase,
public Component
#ifdef USE_ESP32
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
,
public remote_base::RemoteRMTChannel
#endif
@@ -51,7 +53,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase,
void digital_write(bool value);
#if defined(USE_ESP32)
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; }
void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; }
void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; }
@@ -62,7 +64,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase,
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override;
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040)
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period);
void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec);
@@ -73,7 +75,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase,
uint32_t target_time_;
#endif
#ifdef USE_ESP32
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void configure_rmt_();
void wait_for_rmt_();
@@ -100,5 +102,4 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase,
Trigger<> complete_trigger_;
};
} // namespace remote_transmitter
} // namespace esphome
} // namespace esphome::remote_transmitter
@@ -3,10 +3,11 @@
#include "esphome/core/application.h"
#ifdef USE_ESP32
#include <soc/soc_caps.h>
#if SOC_RMT_SUPPORTED
#include <driver/gpio.h>
namespace esphome {
namespace remote_transmitter {
namespace esphome::remote_transmitter {
static const char *const TAG = "remote_transmitter";
@@ -358,7 +359,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
}
#endif
} // namespace remote_transmitter
} // namespace esphome
} // namespace esphome::remote_transmitter
#endif
#endif // SOC_RMT_SUPPORTED
#endif // USE_ESP32
+4 -4
View File
@@ -7,15 +7,15 @@ namespace speed {
static const char *const TAG = "speed.fan";
void SpeedFan::setup() {
// Construct traits before restore so preset modes can be looked up by index
this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, this->direction_ != nullptr, this->speed_count_);
this->traits_.set_supported_preset_modes(this->preset_modes_);
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(*this);
this->write_state_();
}
// Construct traits
this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, this->direction_ != nullptr, this->speed_count_);
this->traits_.set_supported_preset_modes(this->preset_modes_);
}
void SpeedFan::dump_config() { LOG_FAN("", "Speed Fan", this); }
@@ -6,15 +6,15 @@ namespace esphome::template_ {
static const char *const TAG = "template.fan";
void TemplateFan::setup() {
// Construct traits before restore so preset modes can be looked up by index
this->traits_ =
fan::FanTraits(this->has_oscillating_, this->speed_count_ > 0, this->has_direction_, this->speed_count_);
this->traits_.set_supported_preset_modes(this->preset_modes_);
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(*this);
}
// Construct traits
this->traits_ =
fan::FanTraits(this->has_oscillating_, this->speed_count_ > 0, this->has_direction_, this->speed_count_);
this->traits_.set_supported_preset_modes(this->preset_modes_);
}
void TemplateFan::dump_config() { LOG_FAN("", "Template Fan", this); }
@@ -90,7 +90,6 @@ void IDFUARTComponent::setup() {
return;
}
this->uart_num_ = static_cast<uart_port_t>(next_uart_num++);
this->lock_ = xSemaphoreCreateMutex();
#if (SOC_UART_LP_NUM >= 1)
size_t fifo_len = ((this->uart_num_ < SOC_UART_HP_NUM) ? SOC_UART_FIFO_LEN : SOC_LP_UART_FIFO_LEN);
@@ -102,11 +101,7 @@ void IDFUARTComponent::setup() {
this->rx_buffer_size_ = fifo_len * 2;
}
xSemaphoreTake(this->lock_, portMAX_DELAY);
this->load_settings(false);
xSemaphoreGive(this->lock_);
}
void IDFUARTComponent::load_settings(bool dump_config) {
@@ -126,13 +121,20 @@ void IDFUARTComponent::load_settings(bool dump_config) {
return;
}
}
#ifdef USE_UART_WAKE_LOOP_ON_RX
constexpr int event_queue_size = 20;
QueueHandle_t *event_queue_ptr = &this->uart_event_queue_;
#else
constexpr int event_queue_size = 0;
QueueHandle_t *event_queue_ptr = nullptr;
#endif
err = uart_driver_install(this->uart_num_, // UART number
this->rx_buffer_size_, // RX ring buffer size
0, // TX ring buffer size. If zero, driver will not use a TX buffer and TX function will
// block task until all data has been sent out
20, // event queue size/depth
&this->uart_event_queue_, // event queue
0 // Flags used to allocate the interrupt
0, // TX ring buffer size. If zero, driver will not use a TX buffer and TX function will
// block task until all data has been sent out
event_queue_size, // event queue size/depth
event_queue_ptr, // event queue
0 // Flags used to allocate the interrupt
);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err));
@@ -282,9 +284,7 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) {
}
void IDFUARTComponent::write_array(const uint8_t *data, size_t len) {
xSemaphoreTake(this->lock_, portMAX_DELAY);
int32_t write_len = uart_write_bytes(this->uart_num_, data, len);
xSemaphoreGive(this->lock_);
if (write_len != (int32_t) len) {
ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len);
this->mark_failed();
@@ -299,7 +299,6 @@ void IDFUARTComponent::write_array(const uint8_t *data, size_t len) {
bool IDFUARTComponent::peek_byte(uint8_t *data) {
if (!this->check_read_timeout_())
return false;
xSemaphoreTake(this->lock_, portMAX_DELAY);
if (this->has_peek_) {
*data = this->peek_byte_;
} else {
@@ -311,7 +310,6 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) {
this->peek_byte_ = *data;
}
}
xSemaphoreGive(this->lock_);
return true;
}
@@ -320,7 +318,6 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
int32_t read_len = 0;
if (!this->check_read_timeout_(len))
return false;
xSemaphoreTake(this->lock_, portMAX_DELAY);
if (this->has_peek_) {
length_to_read--;
*data = this->peek_byte_;
@@ -329,7 +326,6 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
}
if (length_to_read > 0)
read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS);
xSemaphoreGive(this->lock_);
#ifdef USE_UART_DEBUGGER
for (size_t i = 0; i < len; i++) {
this->debug_callback_.call(UART_DIRECTION_RX, data[i]);
@@ -342,9 +338,7 @@ size_t IDFUARTComponent::available() {
size_t available = 0;
esp_err_t err;
xSemaphoreTake(this->lock_, portMAX_DELAY);
err = uart_get_buffered_data_len(this->uart_num_, &available);
xSemaphoreGive(this->lock_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_get_buffered_data_len failed: %s", esp_err_to_name(err));
@@ -358,9 +352,7 @@ size_t IDFUARTComponent::available() {
void IDFUARTComponent::flush() {
ESP_LOGVV(TAG, " Flushing");
xSemaphoreTake(this->lock_, portMAX_DELAY);
uart_wait_tx_done(this->uart_num_, portMAX_DELAY);
xSemaphoreGive(this->lock_);
}
void IDFUARTComponent::check_logger_conflict() {}
@@ -384,6 +376,13 @@ void IDFUARTComponent::start_rx_event_task_() {
ESP_LOGV(TAG, "RX event task started");
}
// FreeRTOS task that relays UART ISR events to the main loop.
// This task exists because wake_loop_threadsafe() is not ISR-safe (it uses a
// UDP loopback socket), so we need a task as an ISR-to-main-loop trampoline.
// IMPORTANT: This task must NOT call any UART wrapper methods (read_array,
// write_array, peek_byte, etc.) or touch has_peek_/peek_byte_ — all reading
// is done by the main loop. This task only reads from the event queue and
// calls App.wake_loop_threadsafe().
void IDFUARTComponent::rx_event_task_func(void *param) {
auto *self = static_cast<IDFUARTComponent *>(param);
uart_event_t event;
@@ -405,8 +404,14 @@ void IDFUARTComponent::rx_event_task_func(void *param) {
case UART_FIFO_OVF:
case UART_BUFFER_FULL:
ESP_LOGW(TAG, "FIFO overflow or ring buffer full - clearing");
uart_flush_input(self->uart_num_);
// Don't call uart_flush_input() here — this task does not own the read side.
// ESP-IDF examples flush on overflow because the same task handles both events
// and reads, so flush and read are serialized. Here, reads happen on the main
// loop, so flushing from this task races with read_array() and can destroy data
// mid-read. The driver self-heals without an explicit flush: uart_read_bytes()
// calls uart_check_buf_full() after each chunk, which moves stashed FIFO bytes
// into the ring buffer and re-enables RX interrupts once space is freed.
ESP_LOGW(TAG, "FIFO overflow or ring buffer full");
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
App.wake_loop_threadsafe();
#endif
@@ -8,6 +8,13 @@
namespace esphome::uart {
/// ESP-IDF UART driver wrapper.
///
/// Thread safety: All public methods must only be called from the main loop.
/// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's
/// peek byte state (has_peek_/peek_byte_) is not synchronized. The rx_event_task
/// (when enabled) must not call any of these methods — it communicates with the
/// main loop exclusively via App.wake_loop_threadsafe().
class IDFUARTComponent : public UARTComponent, public Component {
public:
void setup() override;
@@ -26,7 +33,9 @@ class IDFUARTComponent : public UARTComponent, public Component {
void flush() override;
uint8_t get_hw_serial_number() { return this->uart_num_; }
#ifdef USE_UART_WAKE_LOOP_ON_RX
QueueHandle_t *get_uart_event_queue() { return &this->uart_event_queue_; }
#endif
/**
* Load the UART with the current settings.
@@ -46,18 +55,20 @@ class IDFUARTComponent : public UARTComponent, public Component {
protected:
void check_logger_conflict() override;
uart_port_t uart_num_;
QueueHandle_t uart_event_queue_;
uart_config_t get_config_();
SemaphoreHandle_t lock_;
bool has_peek_{false};
uint8_t peek_byte_;
#ifdef USE_UART_WAKE_LOOP_ON_RX
// RX notification support
// RX notification support — runs on a separate FreeRTOS task.
// IMPORTANT: rx_event_task_func must NOT call any UART wrapper methods (read_array,
// write_array, etc.) or touch has_peek_/peek_byte_. It must only read from the
// event queue and call App.wake_loop_threadsafe().
void start_rx_event_task_();
static void rx_event_task_func(void *param);
QueueHandle_t uart_event_queue_;
TaskHandle_t rx_event_task_handle_{nullptr};
#endif // USE_UART_WAKE_LOOP_ON_RX
};
@@ -216,23 +216,16 @@ bool WiFiComponent::wifi_apply_hostname_() {
ESP_LOGV(TAG, "Set hostname failed");
}
// inform dhcp server of hostname change using dhcp_renew()
// Update hostname on all lwIP interfaces so DHCP packets include it.
// lwIP includes the hostname in DHCP DISCOVER/REQUEST automatically
// via LWIP_NETIF_HOSTNAME — no dhcp_renew() needed. The hostname is
// fixed at compile time and never changes at runtime.
for (netif *intf = netif_list; intf; intf = intf->next) {
// unconditionally update all known interfaces
#if LWIP_VERSION_MAJOR == 1
intf->hostname = (char *) wifi_station_get_hostname();
#else
intf->hostname = wifi_station_get_hostname();
#endif
if (netif_dhcp_data(intf) != nullptr) {
// renew already started DHCP leases
err_t lwipret = dhcp_renew(intf);
if (lwipret != ERR_OK) {
ESP_LOGW(TAG, "wifi_apply_hostname_(%s): lwIP error %d on interface %c%c (index %d)", intf->hostname,
(int) lwipret, intf->name[0], intf->name[1], intf->num);
ret = false;
}
}
}
return ret;
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.2.0b1"
__version__ = "2026.2.0"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+29 -18
View File
@@ -5,12 +5,12 @@ import hashlib
import logging
from pathlib import Path
import re
import shutil
import subprocess
import urllib.parse
import esphome.config_validation as cv
from esphome.core import CORE, TimePeriodSeconds
from esphome.helpers import rmtree
_LOGGER = logging.getLogger(__name__)
@@ -115,24 +115,35 @@ def clone_or_update(
if not repo_dir.is_dir():
_LOGGER.info("Cloning %s", key)
_LOGGER.debug("Location: %s", repo_dir)
cmd = ["git", "clone", "--depth=1"]
cmd += ["--", url, str(repo_dir)]
run_git_command(cmd)
try:
cmd = ["git", "clone", "--depth=1"]
cmd += ["--", url, str(repo_dir)]
run_git_command(cmd)
if ref is not None:
# We need to fetch the PR branch first, otherwise git will complain
# about missing objects
_LOGGER.info("Fetching %s", ref)
run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir)
run_git_command(["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir)
if ref is not None:
# We need to fetch the PR branch first, otherwise git will complain
# about missing objects
_LOGGER.info("Fetching %s", ref)
run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir)
run_git_command(
["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir
)
if submodules is not None:
_LOGGER.info(
"Initializing submodules (%s) for %s", ", ".join(submodules), key
)
run_git_command(
["git", "submodule", "update", "--init"] + submodules, git_dir=repo_dir
)
if submodules is not None:
_LOGGER.info(
"Initializing submodules (%s) for %s", ", ".join(submodules), key
)
run_git_command(
["git", "submodule", "update", "--init"] + submodules,
git_dir=repo_dir,
)
except GitException:
# Remove incomplete clone to prevent stale state. Without this,
# a failed ref fetch leaves a clone on the default branch, and
# subsequent calls skip the update due to the refresh window.
if repo_dir.is_dir():
rmtree(repo_dir)
raise
else:
# Check refresh needed
@@ -193,7 +204,7 @@ def clone_or_update(
err,
)
_LOGGER.info("Removing broken repository at %s", repo_dir)
shutil.rmtree(repo_dir)
rmtree(repo_dir)
_LOGGER.info("Successfully removed broken repository, re-cloning...")
# Recursively call clone_or_update to re-clone
+18 -2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import platform
import re
import shutil
import stat
import tempfile
from typing import TYPE_CHECKING
from urllib.parse import urlparse
@@ -354,6 +355,23 @@ def is_ha_addon():
return get_bool_env("ESPHOME_IS_HA_ADDON")
def rmtree(path: Path | str) -> None:
"""Remove a directory tree, handling read-only files on Windows.
On Windows, git pack files and other files may be marked read-only,
causing shutil.rmtree to fail. This handles that by removing the
read-only flag and retrying.
"""
def _onerror(func, path, exc_info):
if os.access(path, os.W_OK):
raise exc_info[1].with_traceback(exc_info[2])
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
func(path)
shutil.rmtree(path, onerror=_onerror)
def walk_files(path: Path):
for root, _, files in os.walk(path):
for name in files:
@@ -481,8 +499,6 @@ def list_starts_with(list_, sub):
def file_compare(path1: Path, path2: Path) -> bool:
"""Return True if the files path1 and path2 have the same contents."""
import stat
try:
stat1, stat2 = path1.stat(), path2.stat()
except OSError:
+1 -26
View File
@@ -1,14 +1,10 @@
from collections.abc import Callable
import importlib
import json
import logging
import os
from pathlib import Path
import re
import shutil
import stat
import time
from types import TracebackType
from esphome import loader
from esphome.config import iter_component_configs, iter_components
@@ -25,6 +21,7 @@ from esphome.helpers import (
get_str_env,
is_ha_addon,
read_file,
rmtree,
walk_files,
write_file,
write_file_if_changed,
@@ -404,28 +401,6 @@ def clean_cmake_cache():
pioenvs_cmake_path.unlink()
def _rmtree_error_handler(
func: Callable[[str], object],
path: str,
exc_info: tuple[type[BaseException], BaseException, TracebackType | None],
) -> None:
"""Error handler for shutil.rmtree to handle read-only files on Windows.
On Windows, git pack files and other files may be marked read-only,
causing shutil.rmtree to fail with "Access is denied". This handler
removes the read-only flag and retries the deletion.
"""
if os.access(path, os.W_OK):
raise exc_info[1].with_traceback(exc_info[2])
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
func(path)
def rmtree(path: Path | str) -> None:
"""Remove a directory tree, handling read-only files on Windows."""
shutil.rmtree(path, onerror=_rmtree_error_handler)
def clean_build(clear_pio_cache: bool = True):
# Allow skipping cache cleaning for integration tests
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
+1 -1
View File
@@ -369,7 +369,7 @@ def get_logger_tags():
"api.service",
]
for file in CORE_COMPONENTS_PATH.rglob("*.cpp"):
data = file.read_text()
data = file.read_text(encoding="utf-8")
match = pattern.search(data)
if match:
tags.append(match.group(1))
+2 -2
View File
@@ -27,9 +27,9 @@ sensor:
name: Linearly combined temperatures
sources:
- source: template_temperature1
coeffecient: !lambda "return 0.4 + std::abs(x - 25) * 0.023;"
coefficient: !lambda "return 0.4 + std::abs(x - 25) * 0.023;"
- source: template_temperature2
coeffecient: 1.5
coefficient: 1.5
- platform: combination
type: max
name: Max of combined temperatures
@@ -0,0 +1,12 @@
remote_receiver:
id: rcvr
pin: GPIO2
dump: all
<<: !include common-actions.yaml
binary_sensor:
- platform: remote_receiver
name: Panasonic Remote Input
panasonic:
address: 0x4004
command: 0x100BCBD
@@ -0,0 +1,7 @@
remote_transmitter:
id: xmitr
pin: GPIO2
carrier_duty_percent: 50%
packages:
buttons: !include common-buttons.yaml
@@ -270,6 +270,14 @@ async def test_alarm_control_panel_state_transitions(
# The chime_sensor has chime: true, so opening it while disarmed
# should trigger on_chime callback
# Set up future for the on_ready from opening the chime sensor
# (alarm becomes "not ready" when chime sensor opens).
# We must wait for this BEFORE creating the close future, otherwise
# the open event's log can arrive late and resolve the close future,
# causing the test to proceed before the chime close is processed.
ready_after_chime_open: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_open)
# We're currently DISARMED - open the chime sensor
client.switch_command(chime_switch_info.key, True)
@@ -279,11 +287,18 @@ async def test_alarm_control_panel_state_transitions(
except TimeoutError:
pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}")
# Close the chime sensor and wait for alarm to become ready again
# We need to wait for this transition before testing door sensor,
# otherwise there's a race where the door sensor state change could
# arrive before the chime sensor state change, leaving the alarm in
# a continuous "not ready" state with no on_ready callback fired.
# Wait for the on_ready from the chime sensor opening
try:
await asyncio.wait_for(ready_after_chime_open, timeout=2.0)
except TimeoutError:
pytest.fail(
f"on_ready callback not fired when chime sensor opened. "
f"Log lines: {log_lines[-20:]}"
)
# Now create the future for the close event and close the sensor.
# Since we waited for the open event above, the close event's
# on_ready log cannot be confused with the open event's.
ready_after_chime_close: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_close)
+112 -1
View File
@@ -656,7 +656,7 @@ def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
# Should raise on the second attempt when _recover_broken=False
# This hits the "if not _recover_broken: raise" path
with (
unittest.mock.patch("esphome.git.shutil.rmtree", side_effect=mock_rmtree),
unittest.mock.patch("esphome.git.rmtree", side_effect=mock_rmtree),
pytest.raises(GitCommandError, match="fatal: unable to write new index file"),
):
git.clone_or_update(
@@ -671,3 +671,114 @@ def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
stash_calls = [c for c in call_list if "stash" in c[0][0]]
# Should have exactly two stash calls
assert len(stash_calls) == 2
def test_clone_or_update_cleans_up_on_failed_ref_fetch(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Test that a failed ref fetch removes the incomplete clone directory.
When cloning with a specific ref, if `git clone` succeeds but the
subsequent `git fetch <ref>` fails, the clone directory should be
removed so the next attempt starts fresh instead of finding a stale
clone on the default branch.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "pull/123/head"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "clone":
# Simulate successful clone by creating the directory
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
if cmd_type == "fetch":
raise GitCommandError("fatal: couldn't find remote ref pull/123/head")
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
with pytest.raises(GitCommandError, match="couldn't find remote ref"):
git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
# The incomplete clone directory should have been removed
assert not repo_dir.exists()
# Verify clone was attempted then fetch failed
call_list = mock_run_git_command.call_args_list
clone_calls = [c for c in call_list if "clone" in c[0][0]]
assert len(clone_calls) == 1
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
assert len(fetch_calls) == 1
def test_clone_or_update_stale_clone_is_retried_after_cleanup(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Test that after cleanup, a subsequent call does a fresh clone.
This is the full scenario: first call fails at fetch (directory cleaned up),
second call sees no directory and clones fresh.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "pull/123/head"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
call_count = {"clone": 0, "fetch": 0}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "clone":
call_count["clone"] += 1
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
if cmd_type == "fetch":
call_count["fetch"] += 1
if call_count["fetch"] == 1:
# First fetch fails
raise GitCommandError("fatal: couldn't find remote ref pull/123/head")
# Second fetch succeeds
return ""
if cmd_type == "reset":
return ""
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
# First call: clone succeeds, fetch fails, directory cleaned up
with pytest.raises(GitCommandError, match="couldn't find remote ref"):
git.clone_or_update(url=url, ref=ref, refresh=refresh, domain=domain)
assert not repo_dir.exists()
# Second call: fresh clone + fetch succeeds
result_dir, _ = git.clone_or_update(
url=url, ref=ref, refresh=refresh, domain=domain
)
assert result_dir == repo_dir
assert repo_dir.exists()
assert call_count["clone"] == 2
assert call_count["fetch"] == 2