mirror of
https://github.com/esphome/esphome.git
synced 2026-07-07 23:45:35 +00:00
Compare commits
23 Commits
2026.2.0b1
...
2026.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b61edce92 | |||
| 2c89cded4b | |||
| 896dc4d34d | |||
| ab572c2882 | |||
| 6b8264fcaa | |||
| 973656191b | |||
| d9f493ab7a | |||
| a0c4fa6496 | |||
| fd43bd2b7e | |||
| 5904808804 | |||
| e945e9b659 | |||
| df29cdbf17 | |||
| f6362aa8da | |||
| 1517b7799a | |||
| afa4047089 | |||
| a8a324cbfb | |||
| f6aeef2e68 | |||
| 297dfb0db4 | |||
| c08356b0c1 | |||
| e9bf9bc691 | |||
| ead7937dbf | |||
| 844210519a | |||
| 7c70b2e04e |
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user