diff --git a/CODEOWNERS b/CODEOWNERS index 2975080ba9..a2a0b071c2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -378,6 +378,7 @@ esphome/components/rp2040_pwm/* @jesserockz esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet +esphome/components/runtime_stats/* @bdraco esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ea3268a583..f935518dbc 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -86,8 +86,8 @@ void APIConnection::start() { APIError err = this->helper_->init(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Helper init failed: %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); + ESP_LOGW(TAG, "%s: Helper init failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), + errno); return; } this->client_info_ = helper_->getpeername(); @@ -119,7 +119,7 @@ void APIConnection::loop() { APIError err = this->helper_->loop(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Socket operation failed: %s errno=%d", this->get_client_combined_info().c_str(), + ESP_LOGW(TAG, "%s: Socket operation failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), errno); return; } @@ -136,14 +136,8 @@ void APIConnection::loop() { break; } else if (err != APIError::OK) { on_fatal_error(); - if (err == APIError::SOCKET_READ_FAILED && errno == ECONNRESET) { - ESP_LOGW(TAG, "%s: Connection reset", this->get_client_combined_info().c_str()); - } else if (err == APIError::CONNECTION_CLOSED) { - ESP_LOGW(TAG, "%s: Connection closed", this->get_client_combined_info().c_str()); - } else { - ESP_LOGW(TAG, "%s: Reading failed: %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); - } + ESP_LOGW(TAG, "%s: Reading failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), + errno); return; } else { this->last_traffic_ = now; @@ -1435,6 +1429,24 @@ bool APIConnection::try_send_log_message(int level, const char *tag, const char return this->send_buffer(buffer, SubscribeLogsResponse::MESSAGE_TYPE); } +void APIConnection::complete_authentication_() { + // Early return if already authenticated + if (this->flags_.connection_state == static_cast(ConnectionState::AUTHENTICATED)) { + return; + } + + this->flags_.connection_state = static_cast(ConnectionState::AUTHENTICATED); + ESP_LOGD(TAG, "%s connected", this->get_client_combined_info().c_str()); +#ifdef USE_API_CLIENT_CONNECTED_TRIGGER + this->parent_->get_client_connected_trigger()->trigger(this->client_info_, this->client_peername_); +#endif +#ifdef USE_HOMEASSISTANT_TIME + if (homeassistant::global_homeassistant_time != nullptr) { + this->send_time_request(); + } +#endif +} + HelloResponse APIConnection::hello(const HelloRequest &msg) { this->client_info_ = msg.client_info; this->client_peername_ = this->helper_->getpeername(); @@ -1450,7 +1462,14 @@ HelloResponse APIConnection::hello(const HelloRequest &msg) { resp.server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; resp.name = App.get_name(); +#ifdef USE_API_PASSWORD + // Password required - wait for authentication this->flags_.connection_state = static_cast(ConnectionState::CONNECTED); +#else + // No password configured - auto-authenticate + this->complete_authentication_(); +#endif + return resp; } ConnectResponse APIConnection::connect(const ConnectRequest &msg) { @@ -1463,23 +1482,14 @@ ConnectResponse APIConnection::connect(const ConnectRequest &msg) { // bool invalid_password = 1; resp.invalid_password = !correct; if (correct) { - ESP_LOGD(TAG, "%s connected", this->get_client_combined_info().c_str()); - this->flags_.connection_state = static_cast(ConnectionState::AUTHENTICATED); -#ifdef USE_API_CLIENT_CONNECTED_TRIGGER - this->parent_->get_client_connected_trigger()->trigger(this->client_info_, this->client_peername_); -#endif -#ifdef USE_HOMEASSISTANT_TIME - if (homeassistant::global_homeassistant_time != nullptr) { - this->send_time_request(); - } -#endif + this->complete_authentication_(); } return resp; } DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { DeviceInfoResponse resp{}; #ifdef USE_API_PASSWORD - resp.uses_password = this->parent_->uses_password(); + resp.uses_password = true; #else resp.uses_password = false; #endif @@ -1596,7 +1606,7 @@ bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { APIError err = this->helper_->loop(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Socket operation failed: %s errno=%d", this->get_client_combined_info().c_str(), + ESP_LOGW(TAG, "%s: Socket operation failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), errno); return false; } @@ -1617,12 +1627,8 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { return false; if (err != APIError::OK) { on_fatal_error(); - if (err == APIError::SOCKET_WRITE_FAILED && errno == ECONNRESET) { - ESP_LOGW(TAG, "%s: Connection reset", this->get_client_combined_info().c_str()); - } else { - ESP_LOGW(TAG, "%s: Packet write failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); - } + ESP_LOGW(TAG, "%s: Packet write failed %s errno=%d", this->get_client_combined_info().c_str(), + api_error_to_str(err), errno); return false; } // Do not set last_traffic_ on send @@ -1630,11 +1636,11 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { } void APIConnection::on_unauthenticated_access() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s requested access without authentication", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s access without authentication", this->get_client_combined_info().c_str()); } void APIConnection::on_no_setup_connection() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s requested access without full connection", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s access without full connection", this->get_client_combined_info().c_str()); } void APIConnection::on_fatal_error() { this->helper_->close(); @@ -1799,12 +1805,8 @@ void APIConnection::process_batch_() { this->helper_->write_protobuf_packets(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, packet_info); if (err != APIError::OK && err != APIError::WOULD_BLOCK) { on_fatal_error(); - if (err == APIError::SOCKET_WRITE_FAILED && errno == ECONNRESET) { - ESP_LOGW(TAG, "%s: Connection reset during batch write", this->get_client_combined_info().c_str()); - } else { - ESP_LOGW(TAG, "%s: Batch write failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); - } + ESP_LOGW(TAG, "%s: Batch write failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), + errno); } #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 0051a143de..0a3cb7b4d4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -273,6 +273,9 @@ class APIConnection : public APIServerConnection { ProtoWriteBuffer allocate_batch_message_buffer(uint16_t size); protected: + // Helper function to handle authentication completion + void complete_authentication_(); + // Helper function to fill common entity info fields static void fill_entity_info_base(esphome::EntityBase *entity, InfoResponseProtoMessage &response) { // Set common fields that are shared by all entity types diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index f5be672c9a..750143d7f1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -219,8 +219,6 @@ void APIServer::dump_config() { } #ifdef USE_API_PASSWORD -bool APIServer::uses_password() const { return !this->password_.empty(); } - bool APIServer::check_password(const std::string &password) const { // depend only on input password length const char *a = this->password_.c_str(); @@ -428,7 +426,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { ESP_LOGD(TAG, "Noise PSK saved"); if (make_active) { this->set_timeout(100, [this, psk]() { - ESP_LOGW(TAG, "Disconnecting all clients to reset connections"); + ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); this->set_noise_psk(psk); for (auto &c : this->clients_) { c->send_message(DisconnectRequest()); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index f41064b62b..edbd289421 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -39,7 +39,6 @@ class APIServer : public Component, public Controller { bool teardown() override; #ifdef USE_API_PASSWORD bool check_password(const std::string &password) const; - bool uses_password() const; void set_password(const std::string &password); #endif void set_port(uint16_t port); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 35def6b100..f8539f4be1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -175,26 +175,7 @@ class Proto32Bit { const uint32_t value_; }; -class Proto64Bit { - public: - explicit Proto64Bit(uint64_t value) : value_(value) {} - uint64_t as_fixed64() const { return this->value_; } - int64_t as_sfixed64() const { return static_cast(this->value_); } - double as_double() const { - union { - uint64_t raw; - double value; - } s{}; - s.raw = this->value_; - return s.value; - } - - protected: - const uint64_t value_; -}; - -// Forward declaration needed for method declaration -class ProtoMessage; +// NOTE: Proto64Bit class removed - wire type 1 (64-bit fixed) not supported class ProtoWriteBuffer { public: @@ -261,20 +242,10 @@ class ProtoWriteBuffer { this->write((value >> 16) & 0xFF); this->write((value >> 24) & 0xFF); } - void encode_fixed64(uint32_t field_id, uint64_t value, bool force = false) { - if (value == 0 && !force) - return; - - this->encode_field_raw(field_id, 1); // type 1: 64-bit fixed64 - this->write((value >> 0) & 0xFF); - this->write((value >> 8) & 0xFF); - this->write((value >> 16) & 0xFF); - this->write((value >> 24) & 0xFF); - this->write((value >> 32) & 0xFF); - this->write((value >> 40) & 0xFF); - this->write((value >> 48) & 0xFF); - this->write((value >> 56) & 0xFF); - } + // NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally + // not supported to reduce overhead on embedded systems. All ESPHome devices are + // 32-bit microcontrollers where 64-bit operations are expensive. If 64-bit support + // is needed in the future, the necessary encoding/decoding functions must be added. void encode_float(uint32_t field_id, float value, bool force = false) { if (value == 0.0f && !force) return; @@ -340,7 +311,7 @@ class ProtoMessage { virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } - virtual bool decode_64bit(uint32_t field_id, Proto64Bit value) { return false; } + // NOTE: decode_64bit removed - wire type 1 not supported }; class ProtoSize { @@ -665,33 +636,8 @@ class ProtoSize { total_size += field_id_size + varint(value); } - /** - * @brief Calculates and adds the size of a sint64 field to the total message size - * - * Sint64 fields use ZigZag encoding, which is more efficient for negative values. - */ - static inline void add_sint64_field(uint32_t &total_size, uint32_t field_id_size, int64_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size - } - - // ZigZag encoding for sint64: (n << 1) ^ (n >> 63) - uint64_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 63)); - total_size += field_id_size + varint(zigzag); - } - - /** - * @brief Calculates and adds the size of a sint64 field to the total message size (repeated field version) - * - * Sint64 fields use ZigZag encoding, which is more efficient for negative values. - */ - static inline void add_sint64_field_repeated(uint32_t &total_size, uint32_t field_id_size, int64_t value) { - // Always calculate size for repeated fields - // ZigZag encoding for sint64: (n << 1) ^ (n >> 63) - uint64_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 63)); - total_size += field_id_size + varint(zigzag); - } + // NOTE: sint64 support functions (add_sint64_field, add_sint64_field_repeated) removed + // sint64 type is not supported by ESPHome API to reduce overhead on embedded systems /** * @brief Calculates and adds the size of a string/bytes field to the total message size diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 9f50fd779a..867a8efe49 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -1,11 +1,16 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import CONF_PIN +from esphome.const import CONF_ID, CONF_NAME, CONF_NUMBER, CONF_PIN +from esphome.core import CORE from .. import gpio_ns +_LOGGER = logging.getLogger(__name__) + GPIOBinarySensor = gpio_ns.class_( "GPIOBinarySensor", binary_sensor.BinarySensor, cg.Component ) @@ -41,6 +46,22 @@ async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_use_interrupt(config[CONF_USE_INTERRUPT])) - if config[CONF_USE_INTERRUPT]: + # Check for ESP8266 GPIO16 interrupt limitation + # GPIO16 on ESP8266 is a special pin that doesn't support interrupts through + # the Arduino attachInterrupt() function. This is the only known GPIO pin + # across all supported platforms that has this limitation, so we handle it + # here instead of in the platform-specific code. + use_interrupt = config[CONF_USE_INTERRUPT] + if use_interrupt and CORE.is_esp8266 and config[CONF_PIN][CONF_NUMBER] == 16: + _LOGGER.warning( + "GPIO binary_sensor '%s': GPIO16 on ESP8266 doesn't support interrupts. " + "Falling back to polling mode (same as in ESPHome <2025.7). " + "The sensor will work exactly as before, but other pins have better " + "performance with interrupts.", + config.get(CONF_NAME, config[CONF_ID]), + ) + use_interrupt = False + + cg.add(var.set_use_interrupt(use_interrupt)) + if use_interrupt: cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 840511da69..f836a1eca5 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -29,9 +29,9 @@ from ..defines import ( ) from ..helpers import add_lv_use, lvgl_components_required from ..lv_validation import ( - angle, get_end_value, get_start_value, + lv_angle, lv_bool, lv_color, lv_float, @@ -162,7 +162,7 @@ SCALE_SCHEMA = cv.Schema( cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_, cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_, cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360), - cv.Optional(CONF_ROTATION): angle, + cv.Optional(CONF_ROTATION): lv_angle, cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA), } ) @@ -187,7 +187,7 @@ class MeterType(WidgetType): for scale_conf in config.get(CONF_SCALES, ()): rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 if CONF_ROTATION in scale_conf: - rotation = scale_conf[CONF_ROTATION] // 10 + rotation = await lv_angle.process(scale_conf[CONF_ROTATION]) with LocalVariable( "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) ) as meter_var: @@ -205,21 +205,20 @@ class MeterType(WidgetType): var, meter_var, ticks[CONF_COUNT], - ticks[CONF_WIDTH], - ticks[CONF_LENGTH], + await size.process(ticks[CONF_WIDTH]), + await size.process(ticks[CONF_LENGTH]), color, ) if CONF_MAJOR in ticks: major = ticks[CONF_MAJOR] - color = await lv_color.process(major[CONF_COLOR]) lv.meter_set_scale_major_ticks( var, meter_var, major[CONF_STRIDE], - major[CONF_WIDTH], - major[CONF_LENGTH], - color, - major[CONF_LABEL_GAP], + await size.process(major[CONF_WIDTH]), + await size.process(major[CONF_LENGTH]), + await lv_color.process(major[CONF_COLOR]), + await size.process(major[CONF_LABEL_GAP]), ) for indicator in scale_conf.get(CONF_INDICATORS, ()): (t, v) = next(iter(indicator.items())) @@ -233,7 +232,11 @@ class MeterType(WidgetType): lv_assign( ivar, lv_expr.meter_add_needle_line( - var, meter_var, v[CONF_WIDTH], color, v[CONF_R_MOD] + var, + meter_var, + await size.process(v[CONF_WIDTH]), + color, + await size.process(v[CONF_R_MOD]), ), ) if t == CONF_ARC: @@ -241,7 +244,11 @@ class MeterType(WidgetType): lv_assign( ivar, lv_expr.meter_add_arc( - var, meter_var, v[CONF_WIDTH], color, v[CONF_R_MOD] + var, + meter_var, + await size.process(v[CONF_WIDTH]), + color, + await size.process(v[CONF_R_MOD]), ), ) if t == CONF_TICK_STYLE: @@ -257,7 +264,7 @@ class MeterType(WidgetType): color_start, color_end, v[CONF_LOCAL], - v[CONF_WIDTH], + size.process(v[CONF_WIDTH]), ), ) if t == CONF_IMAGE: diff --git a/esphome/components/runtime_stats/__init__.py b/esphome/components/runtime_stats/__init__.py new file mode 100644 index 0000000000..a36e8bfd28 --- /dev/null +++ b/esphome/components/runtime_stats/__init__.py @@ -0,0 +1,34 @@ +""" +Runtime statistics component for ESPHome. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@bdraco"] + +CONF_LOG_INTERVAL = "log_interval" + +runtime_stats_ns = cg.esphome_ns.namespace("runtime_stats") +RuntimeStatsCollector = runtime_stats_ns.class_("RuntimeStatsCollector") + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(RuntimeStatsCollector), + cv.Optional( + CONF_LOG_INTERVAL, default="60s" + ): cv.positive_time_period_milliseconds, + } +) + + +async def to_code(config): + """Generate code for the runtime statistics component.""" + # Define USE_RUNTIME_STATS when this component is used + cg.add_define("USE_RUNTIME_STATS") + + # Create the runtime stats instance (constructor sets global_runtime_stats) + var = cg.new_Pvariable(config[CONF_ID]) + + cg.add(var.set_log_interval(config[CONF_LOG_INTERVAL])) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp new file mode 100644 index 0000000000..8f5d5daf01 --- /dev/null +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -0,0 +1,102 @@ +#include "runtime_stats.h" + +#ifdef USE_RUNTIME_STATS + +#include "esphome/core/component.h" +#include + +namespace esphome { + +namespace runtime_stats { + +RuntimeStatsCollector::RuntimeStatsCollector() : log_interval_(60000), next_log_time_(0) { + global_runtime_stats = this; +} + +void RuntimeStatsCollector::record_component_time(Component *component, uint32_t duration_ms, uint32_t current_time) { + if (component == nullptr) + return; + + // Check if we have cached the name for this component + auto name_it = this->component_names_cache_.find(component); + if (name_it == this->component_names_cache_.end()) { + // First time seeing this component, cache its name + const char *source = component->get_component_source(); + this->component_names_cache_[component] = source; + this->component_stats_[source].record_time(duration_ms); + } else { + this->component_stats_[name_it->second].record_time(duration_ms); + } + + if (this->next_log_time_ == 0) { + this->next_log_time_ = current_time + this->log_interval_; + return; + } +} + +void RuntimeStatsCollector::log_stats_() { + ESP_LOGI(TAG, "Component Runtime Statistics"); + ESP_LOGI(TAG, "Period stats (last %" PRIu32 "ms):", this->log_interval_); + + // First collect stats we want to display + std::vector stats_to_display; + + for (const auto &it : this->component_stats_) { + const ComponentRuntimeStats &stats = it.second; + if (stats.get_period_count() > 0) { + ComponentStatPair pair = {it.first, &stats}; + stats_to_display.push_back(pair); + } + } + + // Sort by period runtime (descending) + std::sort(stats_to_display.begin(), stats_to_display.end(), std::greater()); + + // Log top components by period runtime + for (const auto &it : stats_to_display) { + const char *source = it.name; + const ComponentRuntimeStats *stats = it.stats; + + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, + stats->get_period_count(), stats->get_period_avg_time_ms(), stats->get_period_max_time_ms(), + stats->get_period_time_ms()); + } + + // Log total stats since boot + ESP_LOGI(TAG, "Total stats (since boot):"); + + // Re-sort by total runtime for all-time stats + std::sort(stats_to_display.begin(), stats_to_display.end(), + [](const ComponentStatPair &a, const ComponentStatPair &b) { + return a.stats->get_total_time_ms() > b.stats->get_total_time_ms(); + }); + + for (const auto &it : stats_to_display) { + const char *source = it.name; + const ComponentRuntimeStats *stats = it.stats; + + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, + stats->get_total_count(), stats->get_total_avg_time_ms(), stats->get_total_max_time_ms(), + stats->get_total_time_ms()); + } +} + +void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { + if (this->next_log_time_ == 0) + return; + + if (current_time >= this->next_log_time_) { + this->log_stats_(); + this->reset_stats_(); + this->next_log_time_ = current_time + this->log_interval_; + } +} + +} // namespace runtime_stats + +runtime_stats::RuntimeStatsCollector *global_runtime_stats = + nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome + +#endif // USE_RUNTIME_STATS diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h new file mode 100644 index 0000000000..e2f8bee563 --- /dev/null +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -0,0 +1,132 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_RUNTIME_STATS + +#include +#include +#include +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { + +class Component; // Forward declaration + +namespace runtime_stats { + +static const char *const TAG = "runtime_stats"; + +class ComponentRuntimeStats { + public: + ComponentRuntimeStats() + : period_count_(0), + period_time_ms_(0), + period_max_time_ms_(0), + total_count_(0), + total_time_ms_(0), + total_max_time_ms_(0) {} + + void record_time(uint32_t duration_ms) { + // Update period counters + this->period_count_++; + this->period_time_ms_ += duration_ms; + if (duration_ms > this->period_max_time_ms_) + this->period_max_time_ms_ = duration_ms; + + // Update total counters + this->total_count_++; + this->total_time_ms_ += duration_ms; + if (duration_ms > this->total_max_time_ms_) + this->total_max_time_ms_ = duration_ms; + } + + void reset_period_stats() { + this->period_count_ = 0; + this->period_time_ms_ = 0; + this->period_max_time_ms_ = 0; + } + + // Period stats (reset each logging interval) + uint32_t get_period_count() const { return this->period_count_; } + uint32_t get_period_time_ms() const { return this->period_time_ms_; } + uint32_t get_period_max_time_ms() const { return this->period_max_time_ms_; } + float get_period_avg_time_ms() const { + return this->period_count_ > 0 ? this->period_time_ms_ / static_cast(this->period_count_) : 0.0f; + } + + // Total stats (persistent until reboot) + uint32_t get_total_count() const { return this->total_count_; } + uint32_t get_total_time_ms() const { return this->total_time_ms_; } + uint32_t get_total_max_time_ms() const { return this->total_max_time_ms_; } + float get_total_avg_time_ms() const { + return this->total_count_ > 0 ? this->total_time_ms_ / static_cast(this->total_count_) : 0.0f; + } + + protected: + // Period stats (reset each logging interval) + uint32_t period_count_; + uint32_t period_time_ms_; + uint32_t period_max_time_ms_; + + // Total stats (persistent until reboot) + uint32_t total_count_; + uint32_t total_time_ms_; + uint32_t total_max_time_ms_; +}; + +// For sorting components by run time +struct ComponentStatPair { + const char *name; + const ComponentRuntimeStats *stats; + + bool operator>(const ComponentStatPair &other) const { + // Sort by period time as that's what we're displaying in the logs + return stats->get_period_time_ms() > other.stats->get_period_time_ms(); + } +}; + +class RuntimeStatsCollector { + public: + RuntimeStatsCollector(); + + void set_log_interval(uint32_t log_interval) { this->log_interval_ = log_interval; } + uint32_t get_log_interval() const { return this->log_interval_; } + + void record_component_time(Component *component, uint32_t duration_ms, uint32_t current_time); + + // Process any pending stats printing (should be called after component loop) + void process_pending_stats(uint32_t current_time); + + protected: + void log_stats_(); + + void reset_stats_() { + for (auto &it : this->component_stats_) { + it.second.reset_period_stats(); + } + } + + // Use const char* keys for efficiency + // Custom comparator for const char* keys in map + // Without this, std::map would compare pointer addresses instead of string contents, + // causing identical component names at different addresses to be treated as different keys + struct CStrCompare { + bool operator()(const char *a, const char *b) const { return std::strcmp(a, b) < 0; } + }; + std::map component_stats_; + std::map component_names_cache_; + uint32_t log_interval_; + uint32_t next_log_time_; +}; + +} // namespace runtime_stats + +extern runtime_stats::RuntimeStatsCollector + *global_runtime_stats; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome + +#endif // USE_RUNTIME_STATS diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 8ced5b7e18..2aa6acde0e 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1711,162 +1711,161 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c #endif bool WebServer::canHandle(AsyncWebServerRequest *request) const { - if (request->url() == "/") + const auto &url = request->url(); + const auto method = request->method(); + + // Simple URL checks + if (url == "/") return true; #ifdef USE_ARDUINO - if (request->url() == "/events") { + if (url == "/events") return true; - } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE - if (request->url() == "/0.css") + if (url == "/0.css") return true; #endif #ifdef USE_WEBSERVER_JS_INCLUDE - if (request->url() == "/0.js") + if (url == "/0.js") return true; #endif #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - if (request->method() == HTTP_OPTIONS && request->hasHeader(HEADER_CORS_REQ_PNA)) { + if (method == HTTP_OPTIONS && request->hasHeader(HEADER_CORS_REQ_PNA)) return true; - } #endif - // Store the URL to prevent temporary string destruction - // request->url() returns a reference to a String (on Arduino) or std::string (on ESP-IDF) - // UrlMatch stores pointers to the string's data, so we must ensure the string outlives match_url() - const auto &url = request->url(); + // Parse URL for component checks UrlMatch match = match_url(url.c_str(), url.length(), true); if (!match.valid) return false; + + // Common pattern check + bool is_get = method == HTTP_GET; + bool is_post = method == HTTP_POST; + bool is_get_or_post = is_get || is_post; + + if (!is_get_or_post) + return false; + + // GET-only components + if (is_get) { #ifdef USE_SENSOR - if (request->method() == HTTP_GET && match.domain_equals("sensor")) - return true; + if (match.domain_equals("sensor")) + return true; #endif - -#ifdef USE_SWITCH - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("switch")) - return true; -#endif - -#ifdef USE_BUTTON - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("button")) - return true; -#endif - #ifdef USE_BINARY_SENSOR - if (request->method() == HTTP_GET && match.domain_equals("binary_sensor")) - return true; + if (match.domain_equals("binary_sensor")) + return true; #endif - -#ifdef USE_FAN - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("fan")) - return true; -#endif - -#ifdef USE_LIGHT - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("light")) - return true; -#endif - #ifdef USE_TEXT_SENSOR - if (request->method() == HTTP_GET && match.domain_equals("text_sensor")) - return true; + if (match.domain_equals("text_sensor")) + return true; #endif - -#ifdef USE_COVER - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("cover")) - return true; -#endif - -#ifdef USE_NUMBER - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("number")) - return true; -#endif - -#ifdef USE_DATETIME_DATE - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("date")) - return true; -#endif - -#ifdef USE_DATETIME_TIME - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("time")) - return true; -#endif - -#ifdef USE_DATETIME_DATETIME - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("datetime")) - return true; -#endif - -#ifdef USE_TEXT - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("text")) - return true; -#endif - -#ifdef USE_SELECT - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("select")) - return true; -#endif - -#ifdef USE_CLIMATE - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("climate")) - return true; -#endif - -#ifdef USE_LOCK - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("lock")) - return true; -#endif - -#ifdef USE_VALVE - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("valve")) - return true; -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - if ((request->method() == HTTP_GET || request->method() == HTTP_POST) && match.domain_equals("alarm_control_panel")) - return true; -#endif - #ifdef USE_EVENT - if (request->method() == HTTP_GET && match.domain_equals("event")) - return true; + if (match.domain_equals("event")) + return true; #endif + } -#ifdef USE_UPDATE - if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain_equals("update")) - return true; + // GET+POST components + if (is_get_or_post) { +#ifdef USE_SWITCH + if (match.domain_equals("switch")) + return true; #endif +#ifdef USE_BUTTON + if (match.domain_equals("button")) + return true; +#endif +#ifdef USE_FAN + if (match.domain_equals("fan")) + return true; +#endif +#ifdef USE_LIGHT + if (match.domain_equals("light")) + return true; +#endif +#ifdef USE_COVER + if (match.domain_equals("cover")) + return true; +#endif +#ifdef USE_NUMBER + if (match.domain_equals("number")) + return true; +#endif +#ifdef USE_DATETIME_DATE + if (match.domain_equals("date")) + return true; +#endif +#ifdef USE_DATETIME_TIME + if (match.domain_equals("time")) + return true; +#endif +#ifdef USE_DATETIME_DATETIME + if (match.domain_equals("datetime")) + return true; +#endif +#ifdef USE_TEXT + if (match.domain_equals("text")) + return true; +#endif +#ifdef USE_SELECT + if (match.domain_equals("select")) + return true; +#endif +#ifdef USE_CLIMATE + if (match.domain_equals("climate")) + return true; +#endif +#ifdef USE_LOCK + if (match.domain_equals("lock")) + return true; +#endif +#ifdef USE_VALVE + if (match.domain_equals("valve")) + return true; +#endif +#ifdef USE_ALARM_CONTROL_PANEL + if (match.domain_equals("alarm_control_panel")) + return true; +#endif +#ifdef USE_UPDATE + if (match.domain_equals("update")) + return true; +#endif + } return false; } void WebServer::handleRequest(AsyncWebServerRequest *request) { - if (request->url() == "/") { + const auto &url = request->url(); + + // Handle static routes first + if (url == "/") { this->handle_index_request(request); return; } #ifdef USE_ARDUINO - if (request->url() == "/events") { + if (url == "/events") { this->events_.add_new_client(this, request); return; } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE - if (request->url() == "/0.css") { + if (url == "/0.css") { this->handle_css_request(request); return; } #endif #ifdef USE_WEBSERVER_JS_INCLUDE - if (request->url() == "/0.js") { + if (url == "/0.js") { this->handle_js_request(request); return; } @@ -1879,147 +1878,85 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif - // See comment in canHandle() for why we store the URL reference - const auto &url = request->url(); + // Parse URL for component routing UrlMatch match = match_url(url.c_str(), url.length(), false); + // Component routing using minimal code repetition + struct ComponentRoute { + const char *domain; + void (WebServer::*handler)(AsyncWebServerRequest *, const UrlMatch &); + }; + + static const ComponentRoute routes[] = { #ifdef USE_SENSOR - if (match.domain_equals("sensor")) { - this->handle_sensor_request(request, match); - return; - } + {"sensor", &WebServer::handle_sensor_request}, #endif - #ifdef USE_SWITCH - if (match.domain_equals("switch")) { - this->handle_switch_request(request, match); - return; - } + {"switch", &WebServer::handle_switch_request}, #endif - #ifdef USE_BUTTON - if (match.domain_equals("button")) { - this->handle_button_request(request, match); - return; - } + {"button", &WebServer::handle_button_request}, #endif - #ifdef USE_BINARY_SENSOR - if (match.domain_equals("binary_sensor")) { - this->handle_binary_sensor_request(request, match); - return; - } + {"binary_sensor", &WebServer::handle_binary_sensor_request}, #endif - #ifdef USE_FAN - if (match.domain_equals("fan")) { - this->handle_fan_request(request, match); - return; - } + {"fan", &WebServer::handle_fan_request}, #endif - #ifdef USE_LIGHT - if (match.domain_equals("light")) { - this->handle_light_request(request, match); - return; - } + {"light", &WebServer::handle_light_request}, #endif - #ifdef USE_TEXT_SENSOR - if (match.domain_equals("text_sensor")) { - this->handle_text_sensor_request(request, match); - return; - } + {"text_sensor", &WebServer::handle_text_sensor_request}, #endif - #ifdef USE_COVER - if (match.domain_equals("cover")) { - this->handle_cover_request(request, match); - return; - } + {"cover", &WebServer::handle_cover_request}, #endif - #ifdef USE_NUMBER - if (match.domain_equals("number")) { - this->handle_number_request(request, match); - return; - } + {"number", &WebServer::handle_number_request}, #endif - #ifdef USE_DATETIME_DATE - if (match.domain_equals("date")) { - this->handle_date_request(request, match); - return; - } + {"date", &WebServer::handle_date_request}, #endif - #ifdef USE_DATETIME_TIME - if (match.domain_equals("time")) { - this->handle_time_request(request, match); - return; - } + {"time", &WebServer::handle_time_request}, #endif - #ifdef USE_DATETIME_DATETIME - if (match.domain_equals("datetime")) { - this->handle_datetime_request(request, match); - return; - } + {"datetime", &WebServer::handle_datetime_request}, #endif - #ifdef USE_TEXT - if (match.domain_equals("text")) { - this->handle_text_request(request, match); - return; - } + {"text", &WebServer::handle_text_request}, #endif - #ifdef USE_SELECT - if (match.domain_equals("select")) { - this->handle_select_request(request, match); - return; - } + {"select", &WebServer::handle_select_request}, #endif - #ifdef USE_CLIMATE - if (match.domain_equals("climate")) { - this->handle_climate_request(request, match); - return; - } + {"climate", &WebServer::handle_climate_request}, #endif - #ifdef USE_LOCK - if (match.domain_equals("lock")) { - this->handle_lock_request(request, match); - - return; - } + {"lock", &WebServer::handle_lock_request}, #endif - #ifdef USE_VALVE - if (match.domain_equals("valve")) { - this->handle_valve_request(request, match); - return; - } + {"valve", &WebServer::handle_valve_request}, #endif - #ifdef USE_ALARM_CONTROL_PANEL - if (match.domain_equals("alarm_control_panel")) { - this->handle_alarm_control_panel_request(request, match); - - return; - } + {"alarm_control_panel", &WebServer::handle_alarm_control_panel_request}, #endif - #ifdef USE_UPDATE - if (match.domain_equals("update")) { - this->handle_update_request(request, match); - return; - } + {"update", &WebServer::handle_update_request}, #endif + }; + + // Check each route + for (const auto &route : routes) { + if (match.domain_equals(route.domain)) { + (this->*route.handler)(request, match); + return; + } + } // No matching handler found - send 404 - ESP_LOGV(TAG, "Request for unknown URL: %s", request->url().c_str()); + ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); request->send(404, "text/plain", "Not Found"); } diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 754bf7d433..b43fadefbe 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -40,4 +40,7 @@ async def to_code(config): if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json - cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.8") + # Use fork with libretiny compatibility fix + cg.add_library( + "https://github.com/bdraco/ESPAsyncWebServer.git#libretiny_Fix", None + ) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d6fab018cc..085b4941d1 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -4,6 +4,9 @@ #include "esphome/core/hal.h" #include #include +#ifdef USE_RUNTIME_STATS +#include "esphome/components/runtime_stats/runtime_stats.h" +#endif #ifdef USE_STATUS_LED #include "esphome/components/status_led/status_led.h" @@ -141,6 +144,14 @@ void Application::loop() { this->in_loop_ = false; this->app_state_ = new_app_state; +#ifdef USE_RUNTIME_STATS + // Process any pending runtime stats printing after all components have run + // This ensures stats printing doesn't affect component timing measurements + if (global_runtime_stats != nullptr) { + global_runtime_stats->process_pending_stats(last_op_end_time); + } +#endif + // Use the last component's end time instead of calling millis() again auto elapsed = last_op_end_time - this->last_loop_; if (elapsed >= this->loop_interval_ || HighFrequencyLoopRequester::is_high_frequency()) { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 9d863e56cd..3b1120bdd7 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -9,6 +9,9 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_RUNTIME_STATS +#include "esphome/components/runtime_stats/runtime_stats.h" +#endif namespace esphome { @@ -395,6 +398,13 @@ uint32_t WarnIfComponentBlockingGuard::finish() { uint32_t curr_time = millis(); uint32_t blocking_time = curr_time - this->started_; + +#ifdef USE_RUNTIME_STATS + // Record component runtime stats + if (global_runtime_stats != nullptr) { + global_runtime_stats->record_component_time(this->component_, blocking_time, curr_time); + } +#endif bool should_warn; if (this->component_ != nullptr) { should_warn = this->component_->should_warn_of_blocking(blocking_time); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fd01491ed1..f6e18d529d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -288,6 +288,37 @@ class TypeInfo(ABC): TYPE_INFO: dict[int, TypeInfo] = {} +# Unsupported 64-bit types that would add overhead for embedded systems +# TYPE_DOUBLE = 1, TYPE_FIXED64 = 6, TYPE_SFIXED64 = 16, TYPE_SINT64 = 18 +UNSUPPORTED_TYPES = {1: "double", 6: "fixed64", 16: "sfixed64", 18: "sint64"} + + +def validate_field_type(field_type: int, field_name: str = "") -> None: + """Validate that the field type is supported by ESPHome API. + + Raises ValueError for unsupported 64-bit types. + """ + if field_type in UNSUPPORTED_TYPES: + type_name = UNSUPPORTED_TYPES[field_type] + field_info = f" (field: {field_name})" if field_name else "" + raise ValueError( + f"64-bit type '{type_name}'{field_info} is not supported by ESPHome API. " + "These types add significant overhead for embedded systems. " + "If you need 64-bit support, please add the necessary encoding/decoding " + "functions to proto.h/proto.cpp first." + ) + + +def get_type_info_for_field(field: descriptor.FieldDescriptorProto) -> TypeInfo: + """Get the appropriate TypeInfo for a field, handling repeated fields. + + Also validates that the field type is supported. + """ + if field.label == 3: # repeated + return RepeatedTypeInfo(field) + validate_field_type(field.type, field.name) + return TYPE_INFO[field.type](field) + def register_type(name: int): """Decorator to register a type with a name and number.""" @@ -707,6 +738,7 @@ class SInt64Type(TypeInfo): class RepeatedTypeInfo(TypeInfo): def __init__(self, field: descriptor.FieldDescriptorProto) -> None: super().__init__(field) + validate_field_type(field.type, field.name) self._ti: TypeInfo = TYPE_INFO[field.type](field) @property @@ -971,10 +1003,7 @@ def calculate_message_estimated_size(desc: descriptor.DescriptorProto) -> int: total_size = 0 for field in desc.field: - if field.label == 3: # repeated - ti = RepeatedTypeInfo(field) - else: - ti = TYPE_INFO[field.type](field) + ti = get_type_info_for_field(field) # Add estimated size for this field total_size += ti.get_estimated_size() @@ -1284,10 +1313,7 @@ def build_base_class( # For base classes, we only declare the fields but don't handle encode/decode # The derived classes will handle encoding/decoding with their specific field numbers for field in common_fields: - if field.label == 3: # repeated - ti = RepeatedTypeInfo(field) - else: - ti = TYPE_INFO[field.type](field) + ti = get_type_info_for_field(field) # Only add field declarations, not encode/decode logic protected_content.extend(ti.protected_content) diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor.py b/tests/component_tests/gpio/test_gpio_binary_sensor.py new file mode 100644 index 0000000000..74fa2ab1c1 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_binary_sensor.py @@ -0,0 +1,69 @@ +"""Tests for the GPIO binary sensor component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_gpio_binary_sensor_basic_setup( + generate_main: Callable[[str | Path], str], +) -> None: + """ + When the GPIO binary sensor is set in the yaml file, it should be registered in main + """ + main_cpp = generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml") + + assert "new gpio::GPIOBinarySensor();" in main_cpp + assert "App.register_binary_sensor" in main_cpp + assert "bs_gpio->set_use_interrupt(true);" in main_cpp + assert "bs_gpio->set_interrupt_type(gpio::INTERRUPT_ANY_EDGE);" in main_cpp + + +def test_gpio_binary_sensor_esp8266_gpio16_disables_interrupt( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Test that ESP8266 GPIO16 automatically disables interrupt mode with a warning + """ + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_binary_sensor_esp8266.yaml" + ) + + # Check that interrupt is disabled for GPIO16 + assert "bs_gpio16->set_use_interrupt(false);" in main_cpp + + # Check that the warning was logged + assert "GPIO16 on ESP8266 doesn't support interrupts" in caplog.text + assert "Falling back to polling mode" in caplog.text + + +def test_gpio_binary_sensor_esp8266_other_pins_use_interrupt( + generate_main: Callable[[str | Path], str], +) -> None: + """ + Test that ESP8266 pins other than GPIO16 still use interrupt mode + """ + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_binary_sensor_esp8266.yaml" + ) + + # GPIO5 should still use interrupts + assert "bs_gpio5->set_use_interrupt(true);" in main_cpp + assert "bs_gpio5->set_interrupt_type(gpio::INTERRUPT_ANY_EDGE);" in main_cpp + + +def test_gpio_binary_sensor_explicit_polling_mode( + generate_main: Callable[[str | Path], str], +) -> None: + """ + Test that explicitly setting use_interrupt: false works + """ + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml" + ) + + assert "bs_polling->set_use_interrupt(false);" in main_cpp diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor.yaml b/tests/component_tests/gpio/test_gpio_binary_sensor.yaml new file mode 100644 index 0000000000..e258fe0cb4 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_binary_sensor.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + +binary_sensor: + - platform: gpio + pin: 5 + name: "Test GPIO Binary Sensor" + id: bs_gpio diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor_esp8266.yaml b/tests/component_tests/gpio/test_gpio_binary_sensor_esp8266.yaml new file mode 100644 index 0000000000..aec26fe572 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_binary_sensor_esp8266.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +binary_sensor: + - platform: gpio + pin: + number: 16 + mode: INPUT_PULLDOWN_16 + name: "GPIO16 Touch Sensor" + id: bs_gpio16 + + - platform: gpio + pin: + number: 5 + mode: INPUT_PULLUP + name: "GPIO5 Button" + id: bs_gpio5 diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml b/tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml new file mode 100644 index 0000000000..7c53e09265 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32dev + +binary_sensor: + - platform: gpio + pin: 5 + name: "Polling Mode Sensor" + id: bs_polling + use_interrupt: false diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 2edc62b6a1..fbcd2a3fba 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -919,21 +919,21 @@ lvgl: text_color: 0xFFFFFF scales: - ticks: - width: 1 + width: !lambda return 1; count: 61 - length: 20 + length: 20% color: 0xFFFFFF range_from: 0 range_to: 60 angle_range: 360 - rotation: 270 + rotation: !lambda return 2700; indicators: - line: opa: 50% id: minute_hand color: 0xFF0000 - r_mod: -1 - width: 3 + r_mod: !lambda return -1; + width: !lambda return 3; - angle_range: 330 rotation: 300 diff --git a/tests/components/runtime_stats/common.yaml b/tests/components/runtime_stats/common.yaml new file mode 100644 index 0000000000..b434d1b5a7 --- /dev/null +++ b/tests/components/runtime_stats/common.yaml @@ -0,0 +1,2 @@ +# Test runtime_stats component with default configuration +runtime_stats: diff --git a/tests/components/runtime_stats/test.esp32-ard.yaml b/tests/components/runtime_stats/test.esp32-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/runtime_stats/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/integration/fixtures/host_mode_api_password.yaml b/tests/integration/fixtures/host_mode_api_password.yaml new file mode 100644 index 0000000000..038b6871e0 --- /dev/null +++ b/tests/integration/fixtures/host_mode_api_password.yaml @@ -0,0 +1,14 @@ +esphome: + name: host-mode-api-password +host: +api: + password: "test_password_123" +logger: + level: DEBUG +# Test sensor to verify connection works +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 42.0; + update_interval: 0.1s diff --git a/tests/integration/fixtures/runtime_stats.yaml b/tests/integration/fixtures/runtime_stats.yaml new file mode 100644 index 0000000000..aad1c275fb --- /dev/null +++ b/tests/integration/fixtures/runtime_stats.yaml @@ -0,0 +1,39 @@ +esphome: + name: runtime-stats-test + +host: + +api: + +logger: + level: DEBUG + logs: + runtime_stats: INFO + +runtime_stats: + log_interval: 1s + +# Add some components that will execute periodically to generate stats +sensor: + - platform: template + name: "Test Sensor 1" + id: test_sensor_1 + lambda: return 42.0; + update_interval: 0.1s + + - platform: template + name: "Test Sensor 2" + id: test_sensor_2 + lambda: return 24.0; + update_interval: 0.2s + +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true + +interval: + - interval: 0.5s + then: + - switch.toggle: test_switch diff --git a/tests/integration/test_host_mode_api_password.py b/tests/integration/test_host_mode_api_password.py new file mode 100644 index 0000000000..098fc38142 --- /dev/null +++ b/tests/integration/test_host_mode_api_password.py @@ -0,0 +1,53 @@ +"""Integration test for API password authentication.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import APIConnectionError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_api_password( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API authentication with password.""" + async with run_compiled(yaml_config): + # Connect with correct password + async with api_client_connected(password="test_password_123") as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.uses_password is True + assert device_info.name == "host-mode-api-password" + + # Subscribe to states to ensure authenticated connection works + loop = asyncio.get_running_loop() + state_future: asyncio.Future[bool] = loop.create_future() + states = {} + + def on_state(state): + states[state.key] = state + if not state_future.done(): + state_future.set_result(True) + + client.subscribe_states(on_state) + + # Wait for at least one state with timeout + try: + await asyncio.wait_for(state_future, timeout=5.0) + except asyncio.TimeoutError: + pytest.fail("No states received within timeout") + + # Should have received at least one state (the test sensor) + assert len(states) > 0 + + # Test with wrong password - should fail + with pytest.raises(APIConnectionError, match="Invalid password"): + async with api_client_connected(password="wrong_password"): + pass # Should not reach here diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py new file mode 100644 index 0000000000..cd8546facc --- /dev/null +++ b/tests/integration/test_runtime_stats.py @@ -0,0 +1,88 @@ +"""Test runtime statistics component.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_runtime_stats( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test runtime stats logs statistics at configured interval and tracks components.""" + loop = asyncio.get_running_loop() + + # Track how many times we see the total stats + stats_count = 0 + first_stats_future = loop.create_future() + second_stats_future = loop.create_future() + + # Track component stats + component_stats_found = set() + + # Patterns to match - need to handle ANSI color codes and timestamps + # The log format is: [HH:MM:SS][color codes][I][tag]: message + total_stats_pattern = re.compile(r"Total stats \(since boot\):") + # Match component names that may include dots (e.g., template.sensor) + component_pattern = re.compile( + r"^\[[^\]]+\].*?\s+([\w.]+):\s+count=(\d+),\s+avg=([\d.]+)ms" + ) + + def check_output(line: str) -> None: + """Check log output for runtime stats messages.""" + nonlocal stats_count + + # Check for total stats line + if total_stats_pattern.search(line): + stats_count += 1 + + if stats_count == 1 and not first_stats_future.done(): + first_stats_future.set_result(True) + elif stats_count == 2 and not second_stats_future.done(): + second_stats_future.set_result(True) + + # Check for component stats + match = component_pattern.match(line) + if match: + component_name = match.group(1) + component_stats_found.add(component_name) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device is connected + device_info = await client.device_info() + assert device_info is not None + + # Wait for first "Total stats" log (should happen at 1s) + try: + await asyncio.wait_for(first_stats_future, timeout=5.0) + except asyncio.TimeoutError: + pytest.fail("First 'Total stats' log not seen within 5 seconds") + + # Wait for second "Total stats" log (should happen at 2s) + try: + await asyncio.wait_for(second_stats_future, timeout=5.0) + except asyncio.TimeoutError: + pytest.fail(f"Second 'Total stats' log not seen. Total seen: {stats_count}") + + # Verify we got at least 2 stats logs + assert stats_count >= 2, ( + f"Expected at least 2 'Total stats' logs, got {stats_count}" + ) + + # Verify we found stats for our components + assert "template.sensor" in component_stats_found, ( + f"Expected template.sensor stats, found: {component_stats_found}" + ) + assert "template.switch" in component_stats_found, ( + f"Expected template.switch stats, found: {component_stats_found}" + )