diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 965e23870d6..ab7a750388c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e2bfe09cee..f4729f211c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.6 + rev: v0.15.8 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index afe4cdb8712..8d297d7b072 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -92,6 +92,7 @@ esphome/components/bmp3xx_i2c/* @latonita esphome/components/bmp3xx_spi/* @latonita esphome/components/bmp581_base/* @danielkent-net @kahrendt esphome/components/bmp581_i2c/* @danielkent-net @kahrendt +esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid esphome/components/bthome_mithermometer/* @nagyrobi diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 86daa9a2bf6..96ee2fb9209 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2512,6 +2512,7 @@ message ListEntitiesInfraredResponse { EntityCategory entity_category = 6; uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; uint32 capabilities = 8; // Bitfield of InfraredCapabilityFlags + uint32 receiver_frequency = 9; // Demodulation frequency of the IR receiver in Hz (0 = unspecified) } // Command to transmit infrared/RF data using raw timings diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d023cd21a8d..0a99adcacfb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1549,6 +1549,7 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection auto *infrared = static_cast(entity); ListEntitiesInfraredResponse msg; msg.capabilities = infrared->get_capability_flags(); + msg.receiver_frequency = infrared->get_traits().get_receiver_frequency_hz(); return fill_and_encode_entity_info(infrared, msg, conn, remaining_size); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f77f4df5451..ae2cd2bae8d 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3657,6 +3657,7 @@ void ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer) const { buffer.encode_uint32(7, this->device_id); #endif buffer.encode_uint32(8, this->capabilities); + buffer.encode_uint32(9, this->receiver_frequency); } uint32_t ListEntitiesInfraredResponse::calculate_size() const { uint32_t size = 0; @@ -3672,6 +3673,7 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, this->device_id); #endif size += ProtoSize::calc_uint32(1, this->capabilities); + size += ProtoSize::calc_uint32(1, this->receiver_frequency); return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 16586e6e9a4..14f6c704ae4 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3041,11 +3041,12 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 135; - static constexpr uint8_t ESTIMATED_SIZE = 44; + static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_infrared_response"); } #endif uint32_t capabilities{0}; + uint32_t receiver_frequency{0}; void encode(ProtoWriteBuffer &buffer) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index a11f3b231e2..640c3473719 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2572,6 +2572,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif dump_field(out, ESPHOME_PSTR("capabilities"), this->capabilities); + dump_field(out, ESPHOME_PSTR("receiver_frequency"), this->receiver_frequency); return out.c_str(); } #endif diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 89a92de31d2..c9d250545bc 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -469,14 +469,18 @@ bool BMP581Component::read_temperature_and_pressure_(float &temperature, float & } bool BMP581Component::reset_() { + // - activates interface (only relevant for SPI mode) // - writes reset command to the command register // - waits for sensor to complete reset + // - activates interface (only relevant for SPI mode) // - returns the Power-On-Reboot interrupt status, which is asserted if successful + // activates communication interface (SPI only) + this->activate_interface(); + // writes reset command to BMP's command register if (!this->bmp_write_byte(BMP581_COMMAND, RESET_COMMAND)) { ESP_LOGE(TAG, "Failed to write reset command"); - return false; } @@ -484,6 +488,9 @@ bool BMP581Component::reset_() { // - round up to 3 ms delay(3); + // reactivates communication interface after reset (SPI only) + this->activate_interface(); + // read interrupt status register if (!this->bmp_read_byte(BMP581_INT_STATUS, &this->int_status_.reg)) { ESP_LOGE(TAG, "Failed to read interrupt status register"); @@ -491,7 +498,7 @@ bool BMP581Component::reset_() { return false; } - // Power-On-Reboot bit is asserted if sensor successfully reset + // power-On-Reboot bit is asserted if sensor successfully reset return this->int_status_.bit.por; } diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index d99c420272a..c3920512e0d 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -87,6 +87,9 @@ class BMP581Component : public PollingComponent { virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; virtual bool bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + // Interface activation function. Only used for SPI interface; no-op for I2C. + virtual void activate_interface() {} + sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; diff --git a/esphome/components/bmp581_spi/__init__.py b/esphome/components/bmp581_spi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/bmp581_spi/bmp581_spi.cpp b/esphome/components/bmp581_spi/bmp581_spi.cpp new file mode 100644 index 00000000000..01435880f08 --- /dev/null +++ b/esphome/components/bmp581_spi/bmp581_spi.cpp @@ -0,0 +1,73 @@ +#include +#include + +#include "bmp581_spi.h" +#include "esphome/components/bmp581_base/bmp581_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::bmp581_spi { + +static const char *const TAG = "bmp581_spi"; + +// OR (|) register with BMP_SPI_READ for read +inline constexpr uint8_t BMP_SPI_READ = 0x80; + +// AND (&) register with BMP_SPI_WRITE for write +inline constexpr uint8_t BMP_SPI_WRITE = 0x7F; + +void BMP581SPIComponent::dump_config() { + BMP581Component::dump_config(); + LOG_SPI_DEVICE(this); +} + +void BMP581SPIComponent::setup() { + this->spi_setup(); + BMP581Component::setup(); +} + +void BMP581SPIComponent::activate_interface() { + // - forces the device into SPI mode using a dummy read + uint8_t dummy_read = 0; + this->bmp_read_byte(bmp581_base::BMP581_CHIP_ID, &dummy_read); +} + +// In SPI mode, only 7 bits of the register addresses are used; the MSB of register address is not used +// and replaced by a read/write bit (RW = ‘0’ for write and RW = ‘1’ for read). +// Example: address 0xF7 is accessed by using SPI register address 0x77. For write access, the byte +// 0x77 is transferred, for read access, the byte 0xF7 is transferred. +// The expressions BMP_SPI_READ (| with register) and BMP_SPI_WRITE (& with register) +// are defined for readability. +// https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmp581-ds004.pdf + +bool BMP581SPIComponent::bmp_read_byte(uint8_t a_register, uint8_t *data) { + this->enable(); + this->transfer_byte(a_register | BMP_SPI_READ); + *data = this->transfer_byte(0); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_write_byte(uint8_t a_register, uint8_t data) { + this->enable(); + this->transfer_byte(a_register & BMP_SPI_WRITE); + this->transfer_byte(data); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register | BMP_SPI_READ); + this->read_array(data, len); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register & BMP_SPI_WRITE); + this->write_array(data, len); + this->disable(); + return true; +} +} // namespace esphome::bmp581_spi diff --git a/esphome/components/bmp581_spi/bmp581_spi.h b/esphome/components/bmp581_spi/bmp581_spi.h new file mode 100644 index 00000000000..57f75588d5e --- /dev/null +++ b/esphome/components/bmp581_spi/bmp581_spi.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/bmp581_base/bmp581_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::bmp581_spi { + +// BMP581 is technically compatible with SPI Mode0 and Mode3. Default to Mode3. +class BMP581SPIComponent : public esphome::bmp581_base::BMP581Component, + public spi::SPIDevice { + public: + void setup() override; + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; + bool bmp_write_byte(uint8_t a_register, uint8_t data) override; + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + bool bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + void dump_config() override; + + protected: + void activate_interface() override; +}; + +} // namespace esphome::bmp581_spi diff --git a/esphome/components/bmp581_spi/sensor.py b/esphome/components/bmp581_spi/sensor.py new file mode 100644 index 00000000000..75f60b24604 --- /dev/null +++ b/esphome/components/bmp581_spi/sensor.py @@ -0,0 +1,48 @@ +import logging + +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.spi import CONF_SPI_MODE +import esphome.config_validation as cv + +from ..bmp581_base import CONFIG_SCHEMA_BASE, to_code_base + +AUTO_LOAD = ["bmp581_base"] +CODEOWNERS = ["@kahrendt", "@danielkent-net"] +DEPENDENCIES = ["spi"] + +_LOGGER = logging.getLogger(__name__) + +VALID_SPI_MODES = { + 0: "MODE0", + "0": "MODE0", + "MODE0": "MODE0", + 3: "MODE3", + "3": "MODE3", + "MODE3": "MODE3", +} + +bmp581_ns = cg.esphome_ns.namespace("bmp581_spi") +BMP581SPIComponent = bmp581_ns.class_( + "BMP581SPIComponent", cg.PollingComponent, spi.SPIDevice +) + + +def check_spi_mode(config): + spi_mode = config.get(CONF_SPI_MODE) + if spi_mode not in VALID_SPI_MODES: + raise cv.Invalid("BMP581 only supports SPI mode 3") + return config + + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend(spi.spi_device_schema(default_mode="mode3")).extend( + {cv.GenerateID(): cv.declare_id(BMP581SPIComponent)} + ), + check_spi_mode, +) + + +async def to_code(config): + var = await to_code_base(config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 5cbe9a5dafb..32cac0961c6 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -367,7 +367,7 @@ optional Climate::restore_state_() { return recovered; } -void Climate::save_state_() { +void Climate::save_state_(const ClimateTraits &traits) { #if (defined(USE_ESP32) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \ !defined(CLANG_TIDY) #pragma GCC diagnostic ignored "-Wclass-memaccess" @@ -382,7 +382,6 @@ void Climate::save_state_() { #endif state.mode = this->mode; - auto traits = this->get_traits(); if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { state.target_temperature_low = this->target_temperature_low; @@ -480,7 +479,7 @@ void Climate::publish_state() { ControllerRegistry::notify_climate_update(this); #endif // Save state - this->save_state_(); + this->save_state_(traits); } ClimateTraits Climate::get_traits() { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index e2cb743c0a6..0251365dd8a 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -335,7 +335,8 @@ class Climate : public EntityBase { /** Internal method to save the state of the climate device to recover memory. This is automatically * called from publish_state() */ - void save_state_(); + void save_state_(const ClimateTraits &traits); + void save_state_() { this->save_state_(this->traits()); } void dump_traits_(const char *tag); diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 1fbf88c2764..0eb37e3029e 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -18,6 +18,7 @@ CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" +CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_STOP_BITS = "stop_bits" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0e216485ac2..91eb913e3df 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1587,7 +1587,7 @@ async def to_code(config): if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF]: cg.add_define("USE_FULL_PRINTF") else: - for symbol in ("vprintf", "printf", "fprintf"): + for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") else: cg.add_build_flag("-DUSE_ARDUINO") diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index c6f03bc363d..386fbbd79d1 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -2,10 +2,11 @@ * Linker wrap stubs for FILE*-based printf functions. * * ESP-IDF SDK components (gpio driver, ringbuf, log_write) reference - * fprintf(), printf(), and vprintf() which pull in newlib's _vfprintf_r - * (~11 KB). This is a separate implementation from _svfprintf_r (used by - * snprintf/vsnprintf) that handles FILE* stream I/O with buffering and - * locking. + * fprintf(), printf(), vprintf(), and vfprintf() which pull in the full + * printf implementation (~11 KB on newlib's _vfprintf_r, ~2.8 KB on + * picolibc's vfprintf). This is a separate implementation from the one + * used by snprintf/vsnprintf that handles FILE* stream I/O with buffering + * and locking. * * ESPHome replaces the ESP-IDF log handler via esp_log_set_vprintf_(), * so the SDK's vprintf() path is dead code at runtime. The fprintf() @@ -70,11 +71,15 @@ int __wrap_printf(const char *fmt, ...) { return len; } +int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + int __wrap_fprintf(FILE *stream, const char *fmt, ...) { va_list ap; va_start(ap, fmt); - char buf[PRINTF_BUFFER_SIZE]; - int len = write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); + int len = __wrap_vfprintf(stream, fmt, ap); va_end(ap); return len; } diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index 59535f499a0..6d91c97cce3 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -101,9 +101,13 @@ class InfraredTraits { bool get_supports_receiver() const { return this->supports_receiver_; } void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } + uint32_t get_receiver_frequency_hz() const { return this->receiver_frequency_hz_; } + void set_receiver_frequency_hz(uint32_t freq) { this->receiver_frequency_hz_ = freq; } + protected: bool supports_transmitter_{false}; bool supports_receiver_{false}; + uint32_t receiver_frequency_hz_{0}; // Demodulation frequency of the IR receiver in Hz (0 = unspecified) }; /// Infrared - Base class for infrared remote control implementations diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 4a4d9fa8604..3218889721c 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -4,6 +4,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import infrared, remote_receiver, remote_transmitter +from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -19,6 +20,7 @@ CONFIG_SCHEMA = cv.All( infrared.infrared_schema(IrRfProxy).extend( { cv.Optional(CONF_FREQUENCY, default=0): cv.frequency, + cv.Optional(CONF_RECEIVER_FREQUENCY): cv.frequency, cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id( remote_receiver.RemoteReceiverComponent ), @@ -33,7 +35,14 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: dict[str, Any]) -> None: """Validate that transmitters have a proper carrier duty cycle.""" - # Only validate if this is an infrared (not RF) configuration with a transmitter + # receiver_frequency is only meaningful for receiver configurations + if CONF_RECEIVER_FREQUENCY in config and CONF_REMOTE_RECEIVER_ID not in config: + raise cv.Invalid( + f"'{CONF_RECEIVER_FREQUENCY}' can only be used with '{CONF_REMOTE_RECEIVER_ID}', " + "not with a transmitter" + ) + + # Only validate duty cycle if this is an infrared (not RF) configuration with a transmitter if config.get(CONF_FREQUENCY, 0) != 0 or CONF_REMOTE_TRANSMITTER_ID not in config: return @@ -75,3 +84,7 @@ async def to_code(config: dict[str, Any]) -> None: if CONF_REMOTE_RECEIVER_ID in config: receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) cg.add(var.set_receiver(receiver)) + + # Set receiver demodulation frequency if specified (metadata only, no hardware effect) + if CONF_RECEIVER_FREQUENCY in config: + cg.add(var.set_receiver_frequency(config[CONF_RECEIVER_FREQUENCY])) diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index f067a6e17a3..05b988f2877 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -22,6 +22,9 @@ class IrRfProxy : public infrared::Infrared { /// Check if this is RF mode (non-zero frequency) bool is_rf() const { return this->frequency_khz_ > 0; } + /// Set the receiver's hardware demodulation frequency in Hz (metadata only, does not affect hardware) + void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } + protected: // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 41bd98de7b6..7c936b51b76 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -385,7 +385,7 @@ void LightCall::transform_parameters_() { !(this->color_mode_ & ColorCapability::WHITE) && // !(this->color_mode_ & ColorCapability::COLOR_TEMPERATURE) && // min_mireds > 0.0f && max_mireds > 0.0f) { - ESP_LOGD(TAG, "'%s': setting cold/warm white channels using white/color temperature values", + ESP_LOGV(TAG, "'%s': setting cold/warm white channels using white/color temperature values", this->parent_->get_name().c_str()); // Only compute cold_white/warm_white from color_temperature if they're not already explicitly set. // This is important for state restoration, where both color_temperature and cold_white/warm_white @@ -432,7 +432,7 @@ ColorMode LightCall::compute_color_mode_() { // Don't change if the current mode is in the intersection (suitable AND supported) if (ColorModeMask::mask_contains(intersection, current_mode)) { - ESP_LOGI(TAG, "'%s': color mode not specified; retaining %s", this->parent_->get_name().c_str(), + ESP_LOGV(TAG, "'%s': color mode not specified; retaining %s", this->parent_->get_name().c_str(), LOG_STR_ARG(color_mode_to_human(current_mode))); return current_mode; } @@ -440,7 +440,7 @@ ColorMode LightCall::compute_color_mode_() { // Use the preferred suitable mode. if (intersection != 0) { ColorMode mode = ColorModeMask::first_value_from_mask(intersection); - ESP_LOGI(TAG, "'%s': color mode not specified; using %s", this->parent_->get_name().c_str(), + ESP_LOGV(TAG, "'%s': color mode not specified; using %s", this->parent_->get_name().c_str(), LOG_STR_ARG(color_mode_to_human(mode))); return mode; } diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index ac17e14312f..612bfbc968c 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -90,7 +90,7 @@ bool Nextion::check_connect_() { #endif // NEXTION_PROTOCOL_LOG ESP_LOGW(TAG, "Not connected"); - comok_sent_ = 0; + this->comok_sent_ = 0; return false; } diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index 2adf314a2e3..4ddbfbee6a9 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -106,7 +106,7 @@ void Nextion::set_component_pressed_foreground_color(const char *component, uint } void Nextion::set_component_pressed_foreground_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", " %s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_foreground_color(const char *component, Color color) { @@ -134,7 +134,7 @@ void Nextion::set_component_pressed_font_color(const char *component, uint16_t c } void Nextion::set_component_pressed_font_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", " %s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_font_color(const char *component, Color color) { diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 6c454ab7459..f59b7080028 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -22,9 +22,9 @@ static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { uint32_t range_size = this->tft_size_ - range_start; ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); - uint32_t range_end = ((upload_first_chunk_sent_ or this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; + uint32_t range_end = ((this->upload_first_chunk_sent_ || this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; ESP_LOGD(TAG, "Range start: %" PRIu32, range_start); - if (range_size <= 0 or range_end <= range_start) { + if (range_size <= 0 || range_end <= range_start) { ESP_LOGE(TAG, "Invalid range end: %" PRIu32 ", size: %" PRIu32, range_end, range_size); return -1; } @@ -34,7 +34,7 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { ESP_LOGV(TAG, "Range: %s", range_header); http_client.addHeader("Range", range_header); int code = http_client.GET(); - if (code != HTTP_CODE_OK and code != HTTP_CODE_PARTIAL_CONTENT) { + if (code != HTTP_CODE_OK && code != HTTP_CODE_PARTIAL_CONTENT) { ESP_LOGW(TAG, "HTTP failed: %s", HTTPClient::errorToString(code).c_str()); return -1; } @@ -80,12 +80,12 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); - upload_first_chunk_sent_ = true; + this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { ESP_LOGW(TAG, "No response from display during upload"); allocator.deallocate(buffer, 4096); @@ -112,7 +112,7 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { allocator.deallocate(buffer, 4096); buffer = nullptr; return range_end + 1; - } else if (recv_string[0] != 0x05 and recv_string[0] != 0x08) { // 0x05 == "ok" + } else if (recv_string[0] != 0x05 && recv_string[0] != 0x08) { // 0x05 == "ok" char hex_buf[format_hex_pretty_size(NEXTION_MAX_RESPONSE_LOG_BYTES)]; ESP_LOGE( TAG, "Invalid response: [%s]", @@ -214,7 +214,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ++tries; } - if (code != 200 and code != 206) { + if (code != 200 && code != 206) { ESP_LOGE(TAG, "HTTP request failed with status %d", code); return this->upload_end_(false); } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1b80adc82eb..d8b3db96679 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -989,9 +989,11 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } // When scanning while connected (roaming), return to home channel between // each scanned channel to maintain the connection (helps with BLE/WiFi coexistence) +#ifdef CONFIG_SOC_WIFI_SUPPORTED if (this->roaming_state_ == RoamingState::SCANNING) { config.coex_background_scan = true; } +#endif esp_err_t err = esp_wifi_scan_start(&config, false); if (err != ESP_OK) { diff --git a/esphome/writer.py b/esphome/writer.py index 4aac16ffd4d..06a22301184 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -476,6 +476,16 @@ def clean_all(configuration: list[str]): data_dirs.append(Path(env_data_dir)) if env_build_path := os.environ.get("ESPHOME_BUILD_PATH"): data_dirs.append(Path(env_build_path)) + if not data_dirs: + # No config files or known data dirs, check current directory + cwd_esphome = Path.cwd() / ".esphome" + if cwd_esphome.is_dir(): + data_dirs.append(cwd_esphome) + else: + _LOGGER.warning( + "No configuration files specified and no .esphome directory found in current directory. " + "Pass YAML files or a configuration directory to clean build artifacts." + ) # Clean build dir for dir in data_dirs: diff --git a/requirements.txt b/requirements.txt index ce735f398ae..c74dd265c75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.5 +cryptography==46.0.6 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 diff --git a/requirements_test.txt b/requirements_test.txt index 1440b20333b..3b277e214dd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.7 # also change in .pre-commit-config.yaml when updating +ruff==0.15.8 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/tests/benchmarks/components/climate/__init__.py b/tests/benchmarks/components/climate/__init__.py new file mode 100644 index 00000000000..b08f67a0956 --- /dev/null +++ b/tests/benchmarks/components/climate/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/climate/bench_climate.cpp b/tests/benchmarks/components/climate/bench_climate.cpp new file mode 100644 index 00000000000..316a72b2b64 --- /dev/null +++ b/tests/benchmarks/components/climate/bench_climate.cpp @@ -0,0 +1,142 @@ +#include + +#include "esphome/components/climate/climate.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Climate for benchmarking — control() is a no-op. +class BenchClimate : public climate::Climate { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + climate::ClimateTraits traits() override { return this->traits_; } + + climate::ClimateTraits traits_; + + protected: + void control(const climate::ClimateCall & /*call*/) override {} +}; + +// Helper to create a typical HVAC climate device for benchmarks. +// Note: setup() is not called (no preferences backend), so save_state_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_hvac_climate(BenchClimate &climate) { + climate.configure("test_climate"); + climate.traits_.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + climate.traits_.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + climate.traits_.set_supported_swing_modes({ + climate::CLIMATE_SWING_OFF, + climate::CLIMATE_SWING_BOTH, + climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, + }); + climate.traits_.set_supported_presets({ + climate::CLIMATE_PRESET_NONE, + climate::CLIMATE_PRESET_HOME, + climate::CLIMATE_PRESET_AWAY, + }); + climate.traits_.set_visual_min_temperature(16.0f); + climate.traits_.set_visual_max_temperature(30.0f); + climate.traits_.set_visual_target_temperature_step(0.5f); + climate.traits_.set_visual_current_temperature_step(0.1f); + climate.traits_.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION); +} + +// --- Climate::publish_state() with temperature update --- +// Measures the publish path for a thermostat reporting state — +// the hot path during HVAC operation. + +static void ClimatePublish_State(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + climate.action = climate::CLIMATE_ACTION_HEATING; + climate.target_temperature = 22.0f; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + climate.current_temperature = 20.0f + static_cast(i % 100) / 10.0f; + climate.publish_state(); + } + benchmark::DoNotOptimize(climate.current_temperature); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimatePublish_State); + +// --- Climate::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void ClimatePublish_WithCallback(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + climate.target_temperature = 22.0f; + + uint64_t callback_count = 0; + climate.add_on_state_callback([&callback_count](climate::Climate & /*c*/) { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + climate.current_temperature = 20.0f + static_cast(i % 100) / 10.0f; + climate.publish_state(); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimatePublish_WithCallback); + +// --- ClimateCall::perform() set target temperature --- +// The most common climate call — adjusting the thermostat setpoint. + +static void ClimateCall_SetTemperature(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float temp = 18.0f + static_cast(i % 25) * 0.5f; + climate.make_call().set_target_temperature(temp).perform(); + } + benchmark::DoNotOptimize(climate.target_temperature); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimateCall_SetTemperature); + +// --- ClimateCall::perform() mode change with fan --- +// Exercises the validation path with multiple fields set. + +static void ClimateCall_ModeChange(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + auto mode = (i % 2 == 0) ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_COOL; + auto fan = (i % 2 == 0) ? climate::CLIMATE_FAN_HIGH : climate::CLIMATE_FAN_LOW; + climate.make_call().set_mode(mode).set_fan_mode(fan).set_target_temperature(22.0f).perform(); + } + benchmark::DoNotOptimize(climate.mode); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimateCall_ModeChange); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/climate/benchmark.yaml b/tests/benchmarks/components/climate/benchmark.yaml new file mode 100644 index 00000000000..8e79ed0ae7b --- /dev/null +++ b/tests/benchmarks/components/climate/benchmark.yaml @@ -0,0 +1 @@ +climate: diff --git a/tests/benchmarks/components/cover/__init__.py b/tests/benchmarks/components/cover/__init__.py new file mode 100644 index 00000000000..b08f67a0956 --- /dev/null +++ b/tests/benchmarks/components/cover/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/cover/bench_cover_publish.cpp b/tests/benchmarks/components/cover/bench_cover_publish.cpp new file mode 100644 index 00000000000..794d967edb8 --- /dev/null +++ b/tests/benchmarks/components/cover/bench_cover_publish.cpp @@ -0,0 +1,107 @@ +#include + +#include "esphome/components/cover/cover.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Cover for benchmarking — control() is a no-op. +class BenchCover : public cover::Cover { + public: + cover::CoverTraits get_traits() override { return this->traits_; } + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + cover::CoverTraits traits_; + + protected: + void control(const cover::CoverCall & /*call*/) override {} +}; + +// --- Cover::publish_state() with position updates --- +// Measures the publish path for a garage door reporting position +// during open/close — the hot path during movement. + +static void CoverPublish_Position(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + cover.traits_.set_supports_tilt(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + cover.position = static_cast(i % 101) / 100.0f; + cover.current_operation = (i % 2 == 0) ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING; + cover.publish_state(false); + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverPublish_Position); + +// --- Cover::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void CoverPublish_WithCallback(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + uint64_t callback_count = 0; + cover.add_on_state_callback([&callback_count]() { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + cover.position = static_cast(i % 101) / 100.0f; + cover.publish_state(false); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverPublish_WithCallback); + +// --- CoverCall::perform() open/close cycle --- +// Measures the full call path: validation + control delegation. + +static void CoverCall_OpenClose(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + if (i % 2 == 0) { + cover.make_call().set_command_open().perform(); + } else { + cover.make_call().set_command_close().perform(); + } + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverCall_OpenClose); + +// --- CoverCall::perform() set position --- +// Measures the position-setting call path. + +static void CoverCall_SetPosition(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float pos = static_cast(i % 101) / 100.0f; + cover.make_call().set_position(pos).perform(); + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverCall_SetPosition); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/cover/benchmark.yaml b/tests/benchmarks/components/cover/benchmark.yaml new file mode 100644 index 00000000000..477724be5ae --- /dev/null +++ b/tests/benchmarks/components/cover/benchmark.yaml @@ -0,0 +1 @@ +cover: diff --git a/tests/benchmarks/components/light/__init__.py b/tests/benchmarks/components/light/__init__.py new file mode 100644 index 00000000000..233a3c246e5 --- /dev/null +++ b/tests/benchmarks/components/light/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components.light import generate_gamma_table +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Light benchmarks need USE_LIGHT_GAMMA_LUT defined and a gamma table + # with external linkage that the benchmark .cpp can reference. + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config): + await original_to_code(config) + cg.add_define("USE_LIGHT_GAMMA_LUT") + # Use the light component's own generate_gamma_table() so the + # benchmark stays in sync with any formula changes. + forward = generate_gamma_table(2.8) + values = ", ".join(f"0x{int(v):04X}" for v in forward) + # Use extern-visible (non-static) array so the benchmark .cpp + # can reference it via extern declaration. + cg.add_global( + cg.RawStatement( + f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};" + ) + ) + + to_code.priority = original_to_code.priority + manifest.to_code = to_code diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp new file mode 100644 index 00000000000..c1ef0c425e0 --- /dev/null +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -0,0 +1,253 @@ +#include + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +// Gamma 2.8 forward LUT generated by the light component's Python codegen +// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table()) +extern const uint16_t bench_gamma_2_8_fwd[256]; + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal LightOutput for benchmarking — no real hardware interaction. +class BenchLightOutput : public light::LightOutput { + public: + light::LightTraits get_traits() override { return this->traits_; } + void write_state(light::LightState * /*state*/) override {} + + light::LightTraits traits_; +}; + +// Test subclass to access protected configure_entity_() for benchmark setup. +class TestLightState : public light::LightState { + public: + using LightState::LightState; + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } +}; + +// Helper to create a configured RGBWW light state for benchmarks. +// Note: setup() is not called (no preferences backend), so save_remote_values_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { + output.traits_.set_supported_color_modes({light::ColorMode::RGB_COLD_WARM_WHITE}); + output.traits_.set_min_mireds(153.0f); + output.traits_.set_max_mireds(500.0f); + light.configure("test_light"); + light.set_default_transition_length(0); + light.set_gamma_correct(2.8f); + light.set_gamma_table(bench_gamma_2_8_fwd); + light.set_restore_mode(light::LIGHT_ALWAYS_OFF); +} + +// --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- +// Measures the full call path: validation, set_immediately_, publish, and save. +// HA sends color_mode explicitly since API 1.6. + +static void LightCall_RGBInstant(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + // Turn on first so subsequent calls are color changes + light.make_call().set_state(true).set_brightness(1.0f).set_color_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float v = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_red(v) + .set_green(1.0f - v) + .set_blue(v * 0.5f) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_RGBInstant); + +// --- LightCall::perform() turn on/off cycle (Home Assistant API path) --- +// HA sends color_mode explicitly since API 1.6, skipping compute_color_mode_(). + +static void LightCall_ToggleOnOff(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.make_call() + .set_state(i % 2 == 0) + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ToggleOnOff); + +// --- LightCall::perform() turn on/off via MQTT --- +// MQTT never sends color_mode, so compute_color_mode_() runs every call. + +static void LightCall_ToggleOnOff_MQTT(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.make_call().set_state(i % 2 == 0).set_transition_length(0).perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ToggleOnOff_MQTT); + +// --- LightCall::perform() with color temperature via MQTT --- +// Exercises the transform_parameters_() path that converts color_temperature +// to cold/warm white fractions. MQTT never sends color_mode, so this also +// hits compute_color_mode_() every call. Modern HA avoids this path entirely +// by converting color temp to CW/WW client-side. + +static void LightCall_ColorTemperature_MQTT(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + // Sweep through color temperature range + float ct = 153.0f + static_cast(i % 348); + light.make_call().set_color_temperature(ct).set_transition_length(0).perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ColorTemperature_MQTT); + +// --- LightCall::perform() with 1s transition (Home Assistant API path) --- +// Exercises start_transition_() which allocates a LightTransformer. +// This is the default HA path when transition_length > 0. + +static void LightCall_Transition(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float v = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_red(v) + .set_green(1.0f - v) + .set_blue(v * 0.5f) + .set_transition_length(1000) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_Transition); + +// --- LightCall::perform() with cold/warm white (Home Assistant API path) --- +// Mirrors what modern HA sends: explicit color_mode with direct cold_white +// and warm_white values. HA converts color temp to CW/WW client-side for +// CWWW lights (API >= 1.6), so this is the primary HA path. + +static void LightCall_ColdWarmWhite(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float frac = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_cold_white(1.0f - frac) + .set_warm_white(frac) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ColdWarmWhite); + +// --- LightState::publish_state() with a remote values listener --- +// Measures listener notification overhead. + +static void LightPublish_WithListener(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + struct TestListener : public light::LightRemoteValuesListener { + void on_light_remote_values_update() override { count_++; } + uint64_t count_{0}; + } listener; + light.add_remote_values_listener(&listener); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.publish_state(); + } + benchmark::DoNotOptimize(listener.count_); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightPublish_WithListener); + +// --- current_values_as_rgbww output conversion with gamma LUT --- +// Measures the output conversion path that real light drivers call +// from write_state() to get hardware PWM values, including gamma +// table lookups via the LUT generated by Python codegen. + +static void LightOutput_RGBWW(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call() + .set_state(true) + .set_brightness(0.8f) + .set_color_brightness(0.6f) + .set_red(1.0f) + .set_green(0.5f) + .set_blue(0.2f) + .set_cold_white(0.7f) + .set_warm_white(0.3f) + .set_transition_length(0) + .perform(); + + float r, g, b, cw, ww; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.current_values_as_rgbww(&r, &g, &b, &cw, &ww); + } + benchmark::DoNotOptimize(r); + benchmark::DoNotOptimize(cw); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightOutput_RGBWW); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/light/benchmark.yaml b/tests/benchmarks/components/light/benchmark.yaml new file mode 100644 index 00000000000..2b7c9385819 --- /dev/null +++ b/tests/benchmarks/components/light/benchmark.yaml @@ -0,0 +1 @@ +light: diff --git a/tests/component_tests/font/.gitattributes b/tests/component_tests/font/.gitattributes new file mode 100644 index 00000000000..4df6726184f --- /dev/null +++ b/tests/component_tests/font/.gitattributes @@ -0,0 +1,2 @@ +*.pcf -text +*.ttf -text diff --git a/tests/component_tests/font/NotoSans-Regular.ttf b/tests/component_tests/font/NotoSans-Regular.ttf new file mode 100644 index 00000000000..a1b8994edea Binary files /dev/null and b/tests/component_tests/font/NotoSans-Regular.ttf differ diff --git a/tests/component_tests/font/__init__.py b/tests/component_tests/font/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/font/test_font.py b/tests/component_tests/font/test_font.py new file mode 100644 index 00000000000..55e27ae84ce --- /dev/null +++ b/tests/component_tests/font/test_font.py @@ -0,0 +1,337 @@ +"""Tests for the font component. + +Focuses on verifying that long multi-byte (Chinese/CJK) glyph strings +are correctly processed through the font configuration pipeline. +""" + +import functools +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.font import ( + CONF_BPP, + CONF_EXTRAS, + CONF_GLYPHSETS, + CONF_IGNORE_MISSING_GLYPHS, + CONF_RAW_GLYPH_ID, + FONT_CACHE, + flatten, + glyph_comparator, + to_code, + validate_font_config, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_GLYPHS, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_SIZE, + CONF_TYPE, +) + +FONT_DIR = Path(__file__).parent +FONT_PATH = FONT_DIR / "NotoSans-Regular.ttf" + +# 200 unique CJK Unified Ideograph characters (U+4E00..U+4EC7) +CHINESE_200 = "".join(chr(cp) for cp in range(0x4E00, 0x4EC8)) + + +def _file_conf() -> dict: + return {CONF_PATH: str(FONT_PATH), CONF_TYPE: "local"} + + +def _make_config( + glyphs: list[str], + *, + ignore_missing: bool = False, + size: int = 20, + bpp: int = 1, + extras: list | None = None, + glyphsets: list | None = None, +) -> dict: + """Build a config dict matching what FONT_SCHEMA produces.""" + return { + CONF_FILE: _file_conf(), + CONF_GLYPHS: glyphs, + CONF_GLYPHSETS: glyphsets or [], + CONF_IGNORE_MISSING_GLYPHS: ignore_missing, + CONF_SIZE: size, + CONF_BPP: bpp, + CONF_EXTRAS: extras or [], + } + + +@pytest.fixture(autouse=True) +def _load_font(): + """Load the test font into FONT_CACHE and clean up afterwards.""" + fc = _file_conf() + FONT_CACHE[fc] = FONT_PATH + yield + FONT_CACHE.store.clear() + + +# ---------- flatten / glyph_comparator helpers ---------- + + +def test_flatten_splits_chinese_string_into_chars(): + """A single string of 200 Chinese characters must become 200 individual chars.""" + result = flatten([CHINESE_200]) + assert len(result) == 200 + assert all(len(c) == 1 for c in result) + assert result[0] == "\u4e00" + assert result[-1] == "\u4ec7" + + +def test_flatten_multiple_chinese_strings(): + """Multiple glyph strings are concatenated then split correctly.""" + s1 = CHINESE_200[:100] + s2 = CHINESE_200[100:] + result = flatten([list(s1), list(s2)]) + assert len(result) == 200 + + +def test_glyph_comparator_orders_chinese_by_utf8(): + """glyph_comparator must order CJK characters by their UTF-8 byte sequence.""" + chars = list(CHINESE_200[:10]) + sorted_chars = sorted(chars, key=functools.cmp_to_key(glyph_comparator)) + # CJK block is contiguous and UTF-8 order matches codepoint order here + assert sorted_chars == chars + + +def test_glyph_comparator_mixed_ascii_and_chinese(): + """ASCII characters sort before CJK characters (lower UTF-8 bytes).""" + assert glyph_comparator("A", "\u4e00") == -1 + assert glyph_comparator("\u4e00", "A") == 1 + assert glyph_comparator("\u4e00", "\u4e00") == 0 + + +# ---------- validate_font_config ---------- + + +def test_long_chinese_glyphs_raises_missing_error(): + """200 Chinese chars not present in NotoSans must raise Invalid with the correct count.""" + config = _make_config([CHINESE_200]) + with pytest.raises(cv.Invalid, match=r"missing 200 glyphs"): + validate_font_config(config) + + +def test_long_chinese_glyphs_error_mentions_overflow(): + """When more than 10 glyphs are missing the error should mention the remainder.""" + config = _make_config([CHINESE_200]) + with pytest.raises(cv.Invalid, match=r"and 190 more"): + validate_font_config(config) + + +def test_duplicate_chinese_glyphs_detected(): + """Duplicate CJK characters within a single glyph string must be caught.""" + duped = "\u4e00\u4e01\u4e00" # first char repeated + config = _make_config([duped]) + with pytest.raises(cv.Invalid, match="duplicate"): + validate_font_config(config) + + +def test_duplicate_chinese_across_strings(): + """Duplicates across separate glyph strings are also caught.""" + config = _make_config(["\u4e00\u4e01", "\u4e01\u4e02"]) + with pytest.raises(cv.Invalid, match="duplicate"): + validate_font_config(config) + + +def test_no_false_duplicates_in_200_unique_chinese(): + """200 unique CJK characters must not trigger the duplicate check.""" + config = _make_config([CHINESE_200]) + # Should not raise duplicate error — it should reach the missing-glyph check instead + with pytest.raises(cv.Invalid, match="missing"): + validate_font_config(config) + + +def test_valid_latin_glyphs_pass_validation(): + """Latin characters present in NotoSans-Regular pass validation without error.""" + config = _make_config(["ABCabc123"]) + result = validate_font_config(config) + assert result is not None + assert result[CONF_SIZE] == 20 + + +def test_long_latin_glyphs_pass_validation(): + """A long string of supported Latin glyphs passes validation.""" + # 95 printable ASCII characters that NotoSans supports + latin = "".join(chr(cp) for cp in range(0x21, 0x7F)) + config = _make_config([latin]) + result = validate_font_config(config) + assert result is not None + + +def test_mixed_latin_and_chinese_glyphs_error(): + """Mixing valid Latin and invalid Chinese chars reports missing Chinese glyphs.""" + chinese_10 = CHINESE_200[:10] + config = _make_config(["ABC", chinese_10]) + with pytest.raises(cv.Invalid, match=r"missing 10 glyphs"): + validate_font_config(config) + + +def test_single_chinese_char_glyph(): + """A single Chinese character is correctly handled as one glyph.""" + config = _make_config(["\u4e00"]) + with pytest.raises(cv.Invalid, match=r"missing 1 glyph[^s]"): + validate_font_config(config) + + +def test_chinese_glyphs_as_individual_list_items(): + """Chinese chars provided as separate list items are handled the same as a single string.""" + chars_as_list = list(CHINESE_200[:50]) + config = _make_config(chars_as_list) + with pytest.raises(cv.Invalid, match=r"missing 50 glyphs"): + validate_font_config(config) + + +# ---------- YAML parsing ---------- + + +def test_yaml_long_latin_glyphs_parsed_and_validated(tmp_path): + """200 Latin Extended chars on a single YAML line are parsed intact and pass validation.""" + from esphome.yaml_util import load_yaml + + latin_long = "".join(chr(cp) for cp in range(0x100, 0x1C8)) + yaml_file = tmp_path / "font_test.yaml" + yaml_file.write_text( + f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{latin_long}"\n', + encoding="utf-8", + ) + + parsed = load_yaml(yaml_file) + raw_glyphs = parsed["font"][0]["glyphs"] + + # YAML must preserve every Unicode character on the single line + assert raw_glyphs == latin_long + assert len(raw_glyphs) == 200 + + # Feed through validate_font_config to confirm all glyphs are accepted + config = _make_config([raw_glyphs]) + result = validate_font_config(config) + assert result is not None + + +@pytest.mark.parametrize( + "glyphs_str", + [ + " ABC", # space at start + "AB CD", # space in middle + "ABC ", # space at end + ], + ids=["start", "middle", "end"], +) +def test_yaml_space_in_glyphs_preserved(tmp_path, glyphs_str): + """A space character in a glyphs string must survive YAML round-trip and validation.""" + from esphome.yaml_util import load_yaml + + yaml_file = tmp_path / "font_test.yaml" + yaml_file.write_text( + f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{glyphs_str}"\n', + encoding="utf-8", + ) + + parsed = load_yaml(yaml_file) + raw_glyphs = parsed["font"][0]["glyphs"] + + assert raw_glyphs == glyphs_str + assert " " in raw_glyphs + + # Space and ASCII letters are all in NotoSans — validation must pass + config = _make_config([raw_glyphs]) + result = validate_font_config(config) + assert result is not None + + +# ---------- to_code generation ---------- + + +# 200 unique Latin Extended characters (U+0100..U+01C7), all present in NotoSans +LATIN_LONG = "".join(chr(cp) for cp in range(0x100, 0x1C8)) + + +@pytest.fixture +def mock_cg(): + """Mock all cg codegen functions used by to_code.""" + with ( + patch("esphome.components.font.cg.add_define") as mock_define, + patch("esphome.components.font.cg.progmem_array") as mock_progmem, + patch("esphome.components.font.cg.static_const_array") as mock_static, + patch("esphome.components.font.cg.new_Pvariable") as mock_new_pvar, + ): + mock_progmem.return_value = MagicMock() + mock_static.return_value = MagicMock() + yield { + "add_define": mock_define, + "progmem_array": mock_progmem, + "static_const_array": mock_static, + "new_Pvariable": mock_new_pvar, + } + + +@pytest.mark.asyncio +async def test_to_code_long_latin_generates_all_glyphs(mock_cg): + """to_code must generate glyph data for every character in a long Latin string.""" + glyph_count = len(LATIN_LONG) # 200 + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + # USE_FONT define must be emitted + mock_cg["add_define"].assert_any_call("USE_FONT") + + # progmem_array receives the combined bitmap data (non-empty) + mock_cg["progmem_array"].assert_called_once() + bitmap_data = mock_cg["progmem_array"].call_args.args[1] + assert len(bitmap_data) > 0 + + # static_const_array receives one entry per unique glyph + mock_cg["static_const_array"].assert_called_once() + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + assert len(glyph_initializer) == glyph_count + + # new_Pvariable is called with the correct glyph count + mock_cg["new_Pvariable"].assert_called_once() + pvar_args = mock_cg["new_Pvariable"].call_args.args + assert pvar_args[2] == glyph_count # len(glyph_initializer) + assert pvar_args[8] == 1 # bpp + + +@pytest.mark.asyncio +async def test_to_code_glyph_entries_contain_expected_fields(mock_cg): + """Each glyph initializer entry must have 7 fields: codepoint, data ptr, advance, offset_x, offset_y, w, h.""" + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + for entry in glyph_initializer: + assert len(entry) == 7, f"Glyph entry should have 7 fields, got {len(entry)}" + codepoint = entry[0] + assert isinstance(codepoint, int) + assert 0x100 <= codepoint <= 0x1C7 + + +@pytest.mark.asyncio +async def test_to_code_glyphs_sorted_by_utf8(mock_cg): + """Glyphs in the initializer must be sorted by UTF-8 byte order.""" + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + codepoints = [entry[0] for entry in glyph_initializer] + assert codepoints == sorted(codepoints) diff --git a/tests/components/bmp581_spi/common.yaml b/tests/components/bmp581_spi/common.yaml new file mode 100644 index 00000000000..f22074f867d --- /dev/null +++ b/tests/components/bmp581_spi/common.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: bmp581_spi + cs_pin: ${cs_pin} + temperature: + name: BMP581 Temperature + iir_filter: 2x + pressure: + name: BMP581 Pressure + oversampling: 128x diff --git a/tests/components/bmp581_spi/test.esp32-idf.yaml b/tests/components/bmp581_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..a3352cf880d --- /dev/null +++ b/tests/components/bmp581_spi/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/bmp581_spi/test.esp8266-ard.yaml b/tests/components/bmp581_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..595f31046ab --- /dev/null +++ b/tests/components/bmp581_spi/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO15 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bmp581_spi/test.rp2040-ard.yaml b/tests/components/bmp581_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..79ea6ce90b0 --- /dev/null +++ b/tests/components/bmp581_spi/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/font/.gitattributes b/tests/components/font/.gitattributes index 63ab00e9f28..4df6726184f 100644 --- a/tests/components/font/.gitattributes +++ b/tests/components/font/.gitattributes @@ -1 +1,2 @@ -*.pcf -text +*.pcf -text +*.ttf -text diff --git a/tests/components/ir_rf_proxy/common-rx.yaml b/tests/components/ir_rf_proxy/common-rx.yaml index 0f758f832d3..37033a128e1 100644 --- a/tests/components/ir_rf_proxy/common-rx.yaml +++ b/tests/components/ir_rf_proxy/common-rx.yaml @@ -8,6 +8,7 @@ infrared: - platform: ir_rf_proxy id: ir_rx name: "IR Receiver" + receiver_frequency: 38kHz remote_receiver_id: ir_receiver # RF 900MHz receiver diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 6ace38a7d7d..940a394c080 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -990,6 +990,47 @@ def test_clean_all_ignores_empty_env_vars( assert marker.exists() +@patch("esphome.writer.CORE") +def test_clean_all_no_args_with_esphome_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with no args cleans .esphome in cwd.""" + esphome_dir = tmp_path / ".esphome" + esphome_dir.mkdir() + (esphome_dir / "dummy.txt").write_text("x") + + from esphome.writer import clean_all + + with ( + caplog.at_level("INFO"), + patch("esphome.writer.Path.cwd", return_value=tmp_path), + ): + clean_all([]) + + assert esphome_dir.exists() + assert not (esphome_dir / "dummy.txt").exists() + + +@patch("esphome.writer.CORE") +def test_clean_all_no_args_no_esphome_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with no args and no .esphome dir warns.""" + from esphome.writer import clean_all + + with ( + caplog.at_level("WARNING"), + patch("esphome.writer.Path.cwd", return_value=tmp_path), + ): + clean_all([]) + + assert "No configuration files specified" in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all( mock_core: MagicMock,