diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 4cfa9e67ec..90fe067b32 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -112,7 +112,9 @@ AGS10_SET_ZERO_POINT_ACTION_MODE = { AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(AGS10Component), - cv.Required(CONF_MODE): cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True), + cv.Required(CONF_MODE): cv.templatable( + cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True) + ), cv.Optional(CONF_VALUE, default=0xFFFF): cv.templatable(cv.uint16_t), }, ) @@ -127,8 +129,10 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( async def ags10setzeropoint_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - mode = await cg.templatable(config.get(CONF_MODE), args, enumerate) + mode = await cg.templatable( + config.get(CONF_MODE), args, AGS10SetZeroPointActionMode + ) cg.add(var.set_mode(mode)) - value = await cg.templatable(config[CONF_VALUE], args, int) + value = await cg.templatable(config[CONF_VALUE], args, cg.uint16) cg.add(var.set_value(value)) return var diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 13d5273ecb..5a86240ab5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -20,6 +20,9 @@ #ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" #endif +#ifdef USE_ESP8266_CRASH_HANDLER +#include "esphome/components/esp8266/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -276,6 +279,9 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); +#endif +#ifdef USE_ESP8266_CRASH_HANDLER + esp8266::crash_handler_log(); #endif } #ifdef USE_API_HOMEASSISTANT_SERVICES diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 78e87793fc..162f4ef605 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -244,132 +244,144 @@ APIError APINoiseFrameHelper::try_read_frame_() { * If an error occurred, returns that error. Only returns OK if the transport is ready for data * traffic. */ +// Split into per-state methods so the compiler doesn't allocate stack space +// for all branches simultaneously. On RP2040 the core0 stack lives in a 4KB +// scratch RAM bank; the Noise crypto path (curve25519) needs ~2KB+ of stack, +// so every byte saved in the caller matters. APIError APINoiseFrameHelper::state_action_() { - int err; - APIError aerr; - if (state_ == State::INITIALIZE) { - HELPER_LOG("Bad state for method: %d", (int) state_); - return APIError::BAD_STATE; + switch (this->state_) { + case State::INITIALIZE: + HELPER_LOG("Bad state for method: %d", (int) this->state_); + return APIError::BAD_STATE; + case State::CLIENT_HELLO: + return this->state_action_client_hello_(); + case State::SERVER_HELLO: + return this->state_action_server_hello_(); + case State::HANDSHAKE: + return this->state_action_handshake_(); + case State::CLOSED: + case State::FAILED: + return APIError::BAD_STATE; + default: + return APIError::OK; } - if (state_ == State::CLIENT_HELLO) { - // waiting for client hello - aerr = this->try_read_frame_(); - if (aerr != APIError::OK) { - return handle_handshake_frame_error_(aerr); - } - // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); - size_t rx_size = this->rx_buf_.size(); - this->prologue_.resize(old_size + 2 + rx_size); - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; - if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); - } - - state_ = State::SERVER_HELLO; +} +APIError APINoiseFrameHelper::state_action_client_hello_() { + // waiting for client hello + APIError aerr = this->try_read_frame_(); + if (aerr != APIError::OK) { + return handle_handshake_frame_error_(aerr); } - if (state_ == State::SERVER_HELLO) { - // send server hello - const auto &name = App.get_name(); - char mac[MAC_ADDRESS_BUFFER_SIZE]; - get_mac_address_into_buffer(mac); - - // Calculate positions and sizes - size_t name_len = name.size() + 1; // including null terminator - size_t name_offset = 1; - size_t mac_offset = name_offset + name_len; - size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE; - - // 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null) - // + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null) - constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE; - uint8_t msg[max_msg_size]; - - // chosen proto - msg[0] = 0x01; - - // node name, terminated by null byte - std::memcpy(msg + name_offset, name.c_str(), name_len); - // node mac, terminated by null byte - std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); - - aerr = write_frame_(msg, total_size); - if (aerr != APIError::OK) - return aerr; - - // start handshake - aerr = init_handshake_(); - if (aerr != APIError::OK) - return aerr; - - state_ = State::HANDSHAKE; + // ignore contents, may be used in future for flags + // Resize for: existing prologue + 2 size bytes + frame data + size_t old_size = this->prologue_.size(); + size_t rx_size = this->rx_buf_.size(); + this->prologue_.resize(old_size + 2 + rx_size); + this->prologue_[old_size] = (uint8_t) (rx_size >> 8); + this->prologue_[old_size + 1] = (uint8_t) rx_size; + if (rx_size > 0) { + std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); } - if (state_ == State::HANDSHAKE) { - int action = noise_handshakestate_get_action(handshake_); - if (action == NOISE_ACTION_READ_MESSAGE) { - // waiting for handshake msg - aerr = this->try_read_frame_(); - if (aerr != APIError::OK) { - return handle_handshake_frame_error_(aerr); - } - if (this->rx_buf_.empty()) { - send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); - return APIError::BAD_HANDSHAKE_ERROR_BYTE; - } else if (this->rx_buf_[0] != 0x00) { - HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]); - send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); - return APIError::BAD_HANDSHAKE_ERROR_BYTE; - } - - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); - err = noise_handshakestate_read_message(handshake_, &mbuf, nullptr); - if (err != 0) { - // Special handling for MAC failure - send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") - : LOG_STR("Handshake error")); - return handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), - APIError::HANDSHAKESTATE_READ_FAILED); - } - - aerr = check_handshake_finished_(); - if (aerr != APIError::OK) - return aerr; - } else if (action == NOISE_ACTION_WRITE_MESSAGE) { - uint8_t buffer[65]; - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); - - err = noise_handshakestate_write_message(handshake_, &mbuf, nullptr); - APIError aerr_write = handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), - APIError::HANDSHAKESTATE_WRITE_FAILED); - if (aerr_write != APIError::OK) - return aerr_write; - buffer[0] = 0x00; // success - - aerr = write_frame_(buffer, mbuf.size + 1); - if (aerr != APIError::OK) - return aerr; - aerr = check_handshake_finished_(); - if (aerr != APIError::OK) - return aerr; - } else { - // bad state for action - state_ = State::FAILED; - HELPER_LOG("Bad action for handshake: %d", action); - return APIError::HANDSHAKESTATE_BAD_STATE; - } - } - if (state_ == State::CLOSED || state_ == State::FAILED) { - return APIError::BAD_STATE; - } + state_ = State::SERVER_HELLO; return APIError::OK; } +APIError APINoiseFrameHelper::state_action_server_hello_() { + // send server hello + const auto &name = App.get_name(); + char mac[MAC_ADDRESS_BUFFER_SIZE]; + get_mac_address_into_buffer(mac); + + // Calculate positions and sizes + size_t name_len = name.size() + 1; // including null terminator + size_t name_offset = 1; + size_t mac_offset = name_offset + name_len; + size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE; + + // 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null) + // + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null) + constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE; + uint8_t msg[max_msg_size]; + + // chosen proto + msg[0] = 0x01; + + // node name, terminated by null byte + std::memcpy(msg + name_offset, name.c_str(), name_len); + // node mac, terminated by null byte + std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); + + APIError aerr = write_frame_(msg, total_size); + if (aerr != APIError::OK) + return aerr; + + // start handshake + aerr = init_handshake_(); + if (aerr != APIError::OK) + return aerr; + + state_ = State::HANDSHAKE; + return APIError::OK; +} +APIError APINoiseFrameHelper::state_action_handshake_() { + int action = noise_handshakestate_get_action(this->handshake_); + if (action == NOISE_ACTION_READ_MESSAGE) { + return this->state_action_handshake_read_(); + } else if (action == NOISE_ACTION_WRITE_MESSAGE) { + return this->state_action_handshake_write_(); + } + // bad state for action + this->state_ = State::FAILED; + HELPER_LOG("Bad action for handshake: %d", action); + return APIError::HANDSHAKESTATE_BAD_STATE; +} +APIError APINoiseFrameHelper::state_action_handshake_read_() { + APIError aerr = this->try_read_frame_(); + if (aerr != APIError::OK) { + return this->handle_handshake_frame_error_(aerr); + } + + if (this->rx_buf_.empty()) { + this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); + return APIError::BAD_HANDSHAKE_ERROR_BYTE; + } else if (this->rx_buf_[0] != 0x00) { + HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]); + this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); + return APIError::BAD_HANDSHAKE_ERROR_BYTE; + } + + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); + int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr); + if (err != 0) { + // Special handling for MAC failure + this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") + : LOG_STR("Handshake error")); + return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), + APIError::HANDSHAKESTATE_READ_FAILED); + } + + return this->check_handshake_finished_(); +} +APIError APINoiseFrameHelper::state_action_handshake_write_() { + uint8_t buffer[65]; + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); + + int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr); + APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), + APIError::HANDSHAKESTATE_WRITE_FAILED); + if (aerr != APIError::OK) + return aerr; + buffer[0] = 0x00; // success + + aerr = this->write_frame_(buffer, mbuf.size + 1); + if (aerr != APIError::OK) + return aerr; + return this->check_handshake_finished_(); +} void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) { // Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes uint8_t data[32]; diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index a6b17ff3b9..f44bde0755 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -26,6 +26,11 @@ class APINoiseFrameHelper final : public APIFrameHelper { protected: APIError state_action_(); + APIError state_action_client_hello_(); + APIError state_action_server_hello_(); + APIError state_action_handshake_(); + APIError state_action_handshake_read_(); + APIError state_action_handshake_write_(); APIError try_read_frame_(); APIError write_frame_(const uint8_t *data, uint16_t len); APIError init_handshake_(); diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index b1c491805e..2a2a645132 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -16,7 +16,7 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o } void Button::press() { - ESP_LOGD(TAG, "'%s' Pressed.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Pressed.", this->get_name().c_str()); this->press_action(); this->press_callback_.call(); } diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 4d79a0a31b..67d76a59d9 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -117,8 +117,9 @@ FULL_DISPLAY_SCHEMA.add_extra(_validate_test_card) async def setup_display_core_(var, config): - if CONF_ROTATION in config: - cg.add(var.set_rotation(DISPLAY_ROTATIONS[config[CONF_ROTATION]])) + if rotation := config.get(CONF_ROTATION, 0): + # Default initialised value for rotation is 0 + cg.add(var.set_rotation(DISPLAY_ROTATIONS[rotation])) if (auto_clear := config.get(CONF_AUTO_CLEAR_ENABLED)) is not None: # Default to true if pages or lambda is specified. Ideally this would be done during validation, but diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 2657071f45..658f9e2c4a 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -175,9 +175,7 @@ async def to_code(config): *model.get_constructor_args(config), ) - # Rotation is handled by setting the transform - display_config = {k: v for k, v in config.items() if k != CONF_ROTATION} - await display.register_display(var, display_config) + await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) @@ -201,16 +199,6 @@ async def to_code(config): transform[CONF_SWAP_XY] = False else: transform = {x: model.get_default(x, False) for x in TRANSFORM_OPTIONS} - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - elif rotation == 270: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] transform_str = "|".join( { str(getattr(Transform, x.upper())) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index ae1923a916..a2ca311b30 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -97,6 +97,23 @@ bool EPaperBase::reset() { return true; } +void EPaperBase::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (SWAP_XY | MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (MIRROR_Y | MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (SWAP_XY | MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + void EPaperBase::update() { if (this->state_ != EPaperState::IDLE) { ESP_LOGE(TAG, "Display already in state %s", epaper_state_to_string_()); @@ -280,11 +297,11 @@ bool EPaperBase::initialise(bool partial) { bool EPaperBase::rotate_coordinates_(int &x, int &y) { if (!this->get_clipping().inside(x, y)) return false; - if (this->transform_ & SWAP_XY) + if (this->effective_transform_ & SWAP_XY) std::swap(x, y); - if (this->transform_ & MIRROR_X) + if (this->effective_transform_ & MIRROR_X) x = this->width_ - x - 1; - if (this->transform_ & MIRROR_Y) + if (this->effective_transform_ & MIRROR_Y) y = this->height_ - y - 1; if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) return false; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index a743985518..47b4f9f72d 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/display/display_buffer.h" +#include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" @@ -51,7 +51,14 @@ class EPaperBase : public Display, void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } - void set_transform(uint8_t transform) { this->transform_ = transform; } + void set_transform(uint8_t transform) { + this->transform_ = transform; + this->update_effective_transform_(); + } + void set_rotation(DisplayRotation rotation) override { + Display::set_rotation(rotation); + this->update_effective_transform_(); + } void set_full_update_every(uint8_t full_update_every) { this->full_update_every_ = full_update_every; } void dump_config() override; @@ -106,8 +113,8 @@ class EPaperBase : public Display, protected: int get_height_internal() override { return this->height_; }; int get_width_internal() override { return this->width_; }; - int get_width() override { return this->transform_ & SWAP_XY ? this->height_ : this->width_; } - int get_height() override { return this->transform_ & SWAP_XY ? this->width_ : this->height_; } + int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } + int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; void process_state_(); @@ -119,6 +126,7 @@ class EPaperBase : public Display, void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); + void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); /** @@ -171,6 +179,7 @@ class EPaperBase : public Display, uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; + uint8_t effective_transform_{}; uint8_t update_count_{}; // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0ce1117262..0d8a221524 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -48,7 +48,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.types import ConfigType -from esphome.writer import clean_cmake_cache +from esphome.writer import clean_build, clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS from .const import ( # noqa @@ -97,8 +97,12 @@ CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_RELEASE = "release" +CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification" +CONF_SIGNING_KEY = "signing_key" +CONF_SIGNING_SCHEME = "signing_scheme" CONF_SRAM1_AS_IRAM = "sram1_as_iram" CONF_SUBTYPE = "subtype" +CONF_VERIFICATION_KEY = "verification_key" ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" @@ -120,6 +124,27 @@ ASSERTION_LEVELS = { "SILENT": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT", } +SIGNING_SCHEMES = { + "rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", + "ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", +} + +# Chip variants that only support one signing scheme for Secure Boot V2. +# Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h. +# Variants not listed in either set support both RSA and ECDSA +# (e.g. C5, C6, H2, P4). New variants should be added to the +# appropriate set if they only support one scheme. +SIGNED_OTA_RSA_ONLY_VARIANTS = { + VARIANT_ESP32, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, +} +SIGNED_OTA_ECC_ONLY_VARIANTS = { + VARIANT_ESP32C2, + VARIANT_ESP32C61, +} + COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", "NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE", @@ -962,6 +987,47 @@ def final_validate(config): ) # disable the rollback feature anyway since it can't be used. advanced[CONF_ENABLE_OTA_ROLLBACK] = False + if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): + scheme = signed_ota[CONF_SIGNING_SCHEME] + variant = config[CONF_VARIANT] + scheme_variant_conflicts = { + "ecdsa256": (SIGNED_OTA_RSA_ONLY_VARIANTS, "rsa3072"), + "rsa3072": (SIGNED_OTA_ECC_ONLY_VARIANTS, "ecdsa256"), + } + if (conflict := scheme_variant_conflicts.get(scheme)) and variant in conflict[ + 0 + ]: + errs.append( + cv.Invalid( + f"Signing scheme '{scheme}' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", + path=[ + CONF_FRAMEWORK, + CONF_ADVANCED, + CONF_SIGNED_OTA_VERIFICATION, + CONF_SIGNING_SCHEME, + ], + ) + ) + if CONF_OTA not in full_config: + _LOGGER.warning( + "Signed OTA verification is enabled but no OTA component is configured. " + "The initial firmware will be signed but OTA updates won't be possible " + "until an OTA component is added." + ) + if CONF_SIGNING_KEY in signed_ota: + _LOGGER.info( + "Signed OTA verification is enabled. Keep your signing key safe! " + "If you lose the signing key, you will NOT be able to OTA update " + "devices running firmware signed with this key. " + "Without the key, you'll need to reflash via serial." + ) + else: + _LOGGER.info( + "Signed OTA verification is configured with a public verification key. " + "Binaries will NOT be signed automatically during build. " + "You must sign them externally before flashing." + ) if errs: raise cv.MultipleInvalid(errs) @@ -1173,6 +1239,18 @@ FRAMEWORK_SCHEMA = cv.Schema( min=8192, max=32768 ), cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, + cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( + cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional( + CONF_SIGNING_SCHEME, default="rsa3072" + ): cv.one_of(*SIGNING_SCHEMES, lower=True), + } + ), + cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), + ), cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, @@ -1878,6 +1956,32 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) cg.add_define("USE_OTA_ROLLBACK") + # Enable signed app verification without hardware secure boot + if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): + add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) + add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", True) + + scheme = signed_ota[CONF_SIGNING_SCHEME] + for key, flag in SIGNING_SCHEMES.items(): + add_idf_sdkconfig_option(flag, scheme == key) + + if CONF_SIGNING_KEY in signed_ota: + # Private key mode — auto-sign binaries during build + add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_SIGNING_KEY", + str(signed_ota[CONF_SIGNING_KEY].resolve()), + ) + else: + # Public key mode — verification only, external signing required + add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_VERIFICATION_KEY", + str(signed_ota[CONF_VERIFICATION_KEY].resolve()), + ) + + cg.add_define("USE_OTA_SIGNED_VERIFICATION") + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( @@ -2195,6 +2299,7 @@ def _write_sdkconfig(): if write_file_if_changed(internal_path, contents): # internal changed, update real one write_file_if_changed(sdk_path, contents) + clean_build(clear_pio_cache=False) def _write_idf_component_yml(): diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 5ef5860687..8d13214259 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -8,6 +8,99 @@ import shutil # noqa: E402 from glob import glob # noqa: E402 +def _parse_sdkconfig(sdkconfig_path): + """Parse sdkconfig file and return a dict of CONFIG_ options.""" + options = {} + try: + for line in sdkconfig_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + # Strip surrounding quotes from string values + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + options[key] = value + except FileNotFoundError: + pass + return options + + +def sign_firmware(source, target, env): + """ + Sign the firmware binary using espsecure.py if signed OTA verification is enabled. + Reads signing configuration from sdkconfig. + """ + build_dir = pathlib.Path(env.subst("$BUILD_DIR")) + project_dir = pathlib.Path(env.subst("$PROJECT_DIR")) + pioenv = env.subst("$PIOENV") + sdkconfig = _parse_sdkconfig(project_dir / f"sdkconfig.{pioenv}") + + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") != "y": + return + + if sdkconfig.get("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES") != "y": + print("Signed OTA verification enabled but build-time signing disabled.") + print("You must sign the firmware externally before flashing.") + return + + signing_key = sdkconfig.get("CONFIG_SECURE_BOOT_SIGNING_KEY") + if not signing_key: + print("Error: CONFIG_SECURE_BOOT_SIGNING_KEY not set in sdkconfig") + env.Exit(1) + return + + signing_key_path = pathlib.Path(signing_key) + if not signing_key_path.exists(): + print(f"Error: Signing key not found: {signing_key_path}") + env.Exit(1) + return + + # ESPHome only exposes RSA3072 and ECDSA256 (both Secure Boot V2 schemes), + # so the espsecure signature version is always 2. + sign_version = "2" + + firmware_name = os.path.basename(env.subst("$PROGNAME")) + ".bin" + firmware_path = build_dir / firmware_name + + if not firmware_path.exists(): + print(f"Error: Firmware binary not found: {firmware_path}") + env.Exit(1) + return + + python_exe = f'"{env.subst("$PYTHONEXE")}"' + unsigned_path = firmware_path.with_suffix(".unsigned.bin") + + # Keep a copy of the unsigned binary + shutil.copyfile(str(firmware_path), str(unsigned_path)) + + cmd = [ + python_exe, + "-m", + "espsecure", + "sign-data", + "--version", + sign_version, + "--keyfile", + str(signing_key_path), + "--output", + str(firmware_path), + str(unsigned_path), + ] + + print(f"Signing firmware with key: {signing_key_path.name}") + result = env.Execute( + env.VerboseAction(" ".join(cmd), "Signing firmware with espsecure") + ) + + if result == 0: + print("Successfully signed firmware") + else: + print(f"Error: espsecure sign_data failed with code {result}") + # Restore unsigned binary on failure + shutil.copyfile(str(unsigned_path), str(firmware_path)) + env.Exit(1) + + def merge_factory_bin(source, target, env): """ Merges all flash sections into a single .factory.bin using esptool. @@ -124,7 +217,8 @@ def esp32_copy_ota_bin(source, target, env): print(f"Copied firmware to {new_file_name}") -# Run merge first, then ota copy second +# Run signing first, then merge, then ota copy +env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", sign_firmware) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_copy_ota_bin) # noqa: F821 diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 2cd2ec67f7..68e5fffe2b 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -399,8 +399,17 @@ void ESP32BLE::loop() { return; } +#ifdef USE_ESP32_BLE_ADVERTISING + if (this->advertising_ != nullptr) { + this->advertising_->loop(); + } +#endif + BLEEvent *ble_event = this->ble_events_.pop(); - while (ble_event != nullptr) { + if (ble_event == nullptr) + return; + + do { switch (ble_event->type_) { #if defined(USE_ESP32_BLE_SERVER) && defined(ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT) case BLEEvent::GATTS: { @@ -488,15 +497,11 @@ void ESP32BLE::loop() { } // Return the event to the pool this->ble_event_pool_.release(ble_event); - ble_event = this->ble_events_.pop(); - } -#ifdef USE_ESP32_BLE_ADVERTISING - if (this->advertising_ != nullptr) { - this->advertising_->loop(); - } -#endif + } while ((ble_event = this->ble_events_.pop()) != nullptr); - // Log dropped events periodically + // Log dropped events - only reachable when events were processed. + // Drops only occur when the queue is full, and only this loop drains it, + // so if pop() returned nullptr above we can skip this check (saves a memw). uint16_t dropped = this->ble_events_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u BLE events due to buffer overflow", dropped); diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 2081145096..fcd3499b15 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -233,6 +233,7 @@ async def to_code(config): cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) + cg.add_define("USE_ESP8266_CRASH_HANDLER") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp new file mode 100644 index 0000000000..91b0cf9082 --- /dev/null +++ b/esphome/components/esp8266/crash_handler.cpp @@ -0,0 +1,235 @@ +#ifdef USE_ESP8266 + +#include "esphome/core/defines.h" +#ifdef USE_ESP8266_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include + +extern "C" { +#include + +// Global reset info struct populated by SDK/Arduino core at boot +extern struct rst_info resetInfo; +} + +// Xtensa windowed-ABI: bits[31:30] encode call type (CALL0=00, CALL4=01, +// CALL8=10, CALL12=11). Mask and force bit 30 to recover the real address. +static constexpr uint32_t XTENSA_ADDR_MASK = 0x3FFFFFFF; +static constexpr uint32_t XTENSA_CODE_BASE = 0x40000000; + +// ESP8266 memory map boundaries for code regions +static constexpr uint32_t IRAM_START = 0x40100000; +static constexpr uint32_t IRAM_END = 0x40108000; // 32KB + +// Linker symbols for the actual firmware IROM section. +// Using these instead of a conservative upper bound (0x40400000) prevents +// false positives from stale stack values beyond the actual flash mapping. +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,readability-identifier-naming,readability-redundant-declaration) +extern void _irom0_text_start(void); +extern void _irom0_text_end(void); +// NOLINTEND(bugprone-reserved-identifier,readability-identifier-naming,readability-redundant-declaration) +} + +// Check if a value looks like a code address in IRAM or flash-mapped IROM. +// IRAM_ATTR as safety net — normally inlined into custom_crash_callback, but +// ensures correctness if the compiler ever chooses not to inline. +static inline bool IRAM_ATTR is_code_addr(uint32_t val) { + uint32_t addr = (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; + return (addr >= IRAM_START && addr < IRAM_END) || + (addr >= (uint32_t) _irom0_text_start && addr < (uint32_t) _irom0_text_end); +} + +// Recover the actual code address from a windowed-ABI return address on the stack. +static inline uint32_t IRAM_ATTR recover_code_addr(uint32_t val) { return (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; } + +// RTC user memory layout for crash backtrace data. +// User-accessible RTC memory: blocks 64-191 (each block = 4 bytes). +// We use blocks 174-191 (last 18 blocks, 72 bytes) to minimize conflicts. +// Store 16 raw candidates, filter to real return addresses at log time. +static constexpr uint8_t RTC_CRASH_BASE = 174; +static constexpr size_t MAX_BACKTRACE = 16; + +// Magic word packs sentinel, version, and count into one uint32_t: +// bits[31:16] = sentinel +// bits[15:8] = version +// bits[7:0] = backtrace count +static constexpr uint8_t CRASH_SENTINEL_BITS = 16; +static constexpr uint8_t CRASH_VERSION_BITS = 8; + +static constexpr uint16_t CRASH_SENTINEL_VALUE = 0xDEAD; +static constexpr uint8_t CRASH_VERSION_VALUE = 1; + +static constexpr uint32_t CRASH_SENTINEL = static_cast(CRASH_SENTINEL_VALUE) << CRASH_SENTINEL_BITS; +static constexpr uint32_t CRASH_VERSION = static_cast(CRASH_VERSION_VALUE) << CRASH_VERSION_BITS; +static constexpr uint32_t CRASH_SENTINEL_MASK = static_cast(0xFFFF) << CRASH_SENTINEL_BITS; +static constexpr uint32_t CRASH_VERSION_MASK = static_cast(0xFF) << CRASH_VERSION_BITS; +static constexpr uint32_t CRASH_COUNT_MASK = 0xFF; + +// Struct layout: 18 RTC blocks (72 bytes): +// [0] = magic (sentinel | version | count) +// [1..16] = up to 16 code addresses from stack scanning +// [17] = epc1 at crash time (to skip duplicates at log time) +struct RtcCrashData { + uint32_t magic; + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t epc1; // Fault PC, used to filter duplicates +}; +static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC blocks"); + +namespace esphome::esp8266 { + +static const char *const TAG = "esp8266"; + +static inline bool is_crash_reason(uint32_t reason) { + return reason == REASON_WDT_RST || reason == REASON_EXCEPTION_RST || reason == REASON_SOFT_WDT_RST; +} + +bool crash_handler_has_data() { return is_crash_reason(resetInfo.reason); } + +// Xtensa exception cause names for the LX106 core (ESP8266). +// Only includes causes that can actually occur on the LX106 — it has no MMU, +// no TLB, no PIF, and no privilege levels, so causes 12-18 and 24-26 are +// impossible and omitted. The numeric cause is always logged as fallback. +// Uses if-else with LOG_STR to avoid CSWTCH jump tables (RAM on ESP8266). +static const LogString *get_exception_cause(uint32_t cause) { + if (cause == 0) + return LOG_STR("IllegalInst"); + if (cause == 2) + return LOG_STR("InstFetchErr"); + if (cause == 3) + return LOG_STR("LoadStoreErr"); + if (cause == 4) + return LOG_STR("Level1Int"); + if (cause == 6) + return LOG_STR("DivByZero"); + if (cause == 9) + return LOG_STR("Alignment"); + if (cause == 20) + return LOG_STR("InstFetchProhibit"); + if (cause == 28) + return LOG_STR("LoadProhibit"); + if (cause == 29) + return LOG_STR("StoreProhibit"); + return nullptr; +} + +static const LogString *get_reset_reason(uint32_t reason) { + if (reason == REASON_WDT_RST) + return LOG_STR("Hardware WDT"); + if (reason == REASON_EXCEPTION_RST) + return LOG_STR("Exception"); + if (reason == REASON_SOFT_WDT_RST) + return LOG_STR("Soft WDT"); + return LOG_STR("Unknown"); +} + +// Read backtrace from RTC user memory into caller-provided buffer. +// Returns the number of valid backtrace entries (0 if no data found). +static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { + RtcCrashData rtc_data; + if (!system_rtc_mem_read(RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) + return 0; + uint32_t magic = rtc_data.magic; + if ((magic & CRASH_SENTINEL_MASK) != CRASH_SENTINEL || (magic & CRASH_VERSION_MASK) != CRASH_VERSION) + return 0; + uint8_t raw_count = magic & CRASH_COUNT_MASK; + if (raw_count > MAX_BACKTRACE) + raw_count = MAX_BACKTRACE; + // Skip any that match epc1 (already reported as the fault PC). + // Note: we cannot verify CALL instructions at addr-3 on ESP8266 because + // reading from IROM causes LoadStoreError due to flash cache conflicts + // (the reading code and target can share a direct-mapped cache line). + // The linker-symbol IROM bounds already eliminate most false positives. + uint8_t out = 0; + for (uint8_t i = 0; i < raw_count && out < max_entries; i++) { + uint32_t addr = rtc_data.backtrace[i]; + if (addr != rtc_data.epc1) + backtrace[out++] = addr; + } + return out; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!is_crash_reason(resetInfo.reason)) + return; + + // Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost). + // Both resetInfo and RTC data survive until the next reset, so this can be + // called multiple times (logger init + API subscribe) with the same result. + uint32_t backtrace[MAX_BACKTRACE]; + uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific + // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match + // the Arduino core's postmortem handler behavior. + static constexpr uint32_t EXCCAUSE_ILLEGAL_INSTRUCTION = 0; + static constexpr uint32_t EXCCAUSE_INTEGER_DIVIDE_BY_ZERO = 6; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_1 = 0x4000dce5; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_2 = 0x4000dd3d; + uint32_t exccause = resetInfo.exccause; + if (exccause == EXCCAUSE_ILLEGAL_INSTRUCTION && + (resetInfo.epc1 == ROM_DIV_ZERO_ADDR_1 || resetInfo.epc1 == ROM_DIV_ZERO_ADDR_2)) { + exccause = EXCCAUSE_INTEGER_DIVIDE_BY_ZERO; + } + const LogString *cause = get_exception_cause(exccause); + if (cause != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s (exccause=%" PRIu32 ")", LOG_STR_ARG(get_reset_reason(resetInfo.reason)), + LOG_STR_ARG(cause), exccause); + } else { + ESP_LOGE(TAG, " Reason: %s (exccause=%" PRIu32 ")", LOG_STR_ARG(get_reset_reason(resetInfo.reason)), exccause); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32, resetInfo.epc1); + if (resetInfo.reason == REASON_EXCEPTION_RST) { + ESP_LOGE(TAG, " EXCVADDR: 0x%08" PRIX32, resetInfo.excvaddr); + } + for (uint8_t i = 0; i < bt_count; i++) { + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32, i, backtrace[i]); + } +} + +} // namespace esphome::esp8266 + +// --- Custom crash callback --- +// Overrides the weak custom_crash_callback() from Arduino core's +// core_esp8266_postmortem.cpp. Called during exception handling before +// the device restarts. We scan the full stack for code addresses and store +// them in RTC user memory (which survives software reset). +extern "C" void IRAM_ATTR custom_crash_callback(struct rst_info *rst_info, uint32_t stack, uint32_t stack_end) { + // No zero-init — only magic, epc1, and backtrace[0..count-1] are read. + // Saves the IRAM cost of a 72-byte zero-init loop. + RtcCrashData data; // NOLINT(cppcoreguidelines-pro-type-member-init) + uint8_t count = 0; + + // Stack pointer from the Xtensa exception frame is always 4-byte aligned. + auto *scan = (uint32_t *) stack; // NOLINT(performance-no-int-to-ptr) + auto *end = (uint32_t *) stack_end; // NOLINT(performance-no-int-to-ptr) + uint32_t epc1 = rst_info->epc1; + + for (; scan < end && count < MAX_BACKTRACE; scan++) { + uint32_t val = *scan; + if (is_code_addr(val)) { + uint32_t addr = recover_code_addr(val); + // Skip epc1 — already reported as the fault PC + if (addr != epc1) + data.backtrace[count++] = addr; + } + } + + data.epc1 = epc1; + data.magic = CRASH_SENTINEL | CRASH_VERSION | count; + + system_rtc_mem_write(RTC_CRASH_BASE, &data, sizeof(data)); +} + +#endif // USE_ESP8266_CRASH_HANDLER +#endif // USE_ESP8266 diff --git a/esphome/components/esp8266/crash_handler.h b/esphome/components/esp8266/crash_handler.h new file mode 100644 index 0000000000..ea3683b834 --- /dev/null +++ b/esphome/components/esp8266/crash_handler.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266_CRASH_HANDLER + +namespace esphome::esp8266 { + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if the previous boot was a crash (exception, WDT, or soft WDT). +bool crash_handler_has_data(); + +} // namespace esphome::esp8266 + +#endif // USE_ESP8266_CRASH_HANDLER +#endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 906fed2b29..f444f03555 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -19,12 +19,13 @@ static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; -// RTC memory layout for preferences: -// - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 96-127) -// - Normal region: RTC words 32-127 (mapped from preference offset 0-95) +// RTC memory layout: +// - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 78-109) +// - Normal region: RTC words 32-109 (mapped from preference offset 0-77) +// - Crash handler: RTC words 110-127 (reserved for crash_handler.cpp backtrace data) static constexpr uint32_t RTC_EBOOT_REGION_WORDS = 32; // Words 0-31 reserved for eboot -static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 96; // Words 32-127 for normal prefs -static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 128 +static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 78; // Words 32-109 for normal prefs +static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 110 // Maximum preference size in words (limited by uint8_t length_words field) static constexpr uint32_t MAX_PREFERENCE_WORDS = 255; diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 17459cabb6..d9f51c677e 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -104,6 +104,8 @@ CONF_CLK_MODE = "clk_mode" CONF_POWER_PIN = "power_pin" CONF_PHY_REGISTERS = "phy_registers" +CONF_INTERFACE = "interface" + CONF_CLOCK_SPEED = "clock_speed" EthernetType = ethernet_ns.enum("EthernetType") @@ -191,6 +193,13 @@ CLK_MODES_DEPRECATED = { "GPIO17_OUT": ("CLK_OUT", 17), } +spi_host_device_t = cg.global_ns.enum("spi_host_device_t") + +SPI_INTERFACE_MAP = { + "spi2": spi_host_device_t.SPI2_HOST, + "spi3": spi_host_device_t.SPI3_HOST, +} + MANUAL_IP_SCHEMA = cv.Schema( { cv.Required(CONF_STATIC_IP): cv.ipv4address, @@ -225,6 +234,24 @@ def _is_framework_spi_polling_mode_supported() -> bool: return False +def _validate_spi_interface(config: ConfigType) -> ConfigType: + """Set default SPI interface or validate user choice against the variant.""" + if not CORE.is_esp32: + return config + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant + from esphome.components.spi import get_hw_interface_list + + has_spi3 = "spi3" in sum(get_hw_interface_list(), []) + if CONF_INTERFACE not in config: + # Only classic ESP32 defaults to spi3; all others default to spi2 + config[CONF_INTERFACE] = ( + "spi3" if get_esp32_variant() == VARIANT_ESP32 else "spi2" + ) + elif config[CONF_INTERFACE] == "spi3" and not has_spi3: + raise cv.Invalid("Interface 'spi3' is not available on this variant.") + return config + + def _validate(config): if CONF_USE_ADDRESS not in config: if CONF_MANUAL_IP in config: @@ -368,6 +395,10 @@ SPI_SCHEMA = cv.All( cv.frequency, cv.int_range(int(8e6), int(80e6)), ), + cv.Optional(CONF_INTERFACE): cv.All( + cv.only_on_esp32, + cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), + ), # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() cv.Optional(CONF_POLLING_INTERVAL): cv.All( cv.only_on_esp32, @@ -378,6 +409,7 @@ SPI_SCHEMA = cv.All( ), ), cv.only_on([Platform.ESP32, Platform.RP2040]), + _validate_spi_interface, ) CONFIG_SCHEMA = cv.All( @@ -408,37 +440,18 @@ def _final_validate_spi(config): return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: return - from esphome.components.esp32 import ( - VARIANT_ESP32C3, - VARIANT_ESP32C5, - VARIANT_ESP32C6, - VARIANT_ESP32C61, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - get_esp32_variant, - ) from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface if spi_configs := fv.full_config.get().get(CONF_SPI): - variant = get_esp32_variant() - if variant in ( - VARIANT_ESP32C3, - VARIANT_ESP32C5, - VARIANT_ESP32C6, - VARIANT_ESP32C61, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - ): - spi_host = "SPI2_HOST" - else: - spi_host = "SPI3_HOST" + # get_spi_interface() returns strings like "SPI2_HOST" + spi_host = f"{config[CONF_INTERFACE].upper()}_HOST" for spi_conf in spi_configs: if (index := spi_conf.get(CONF_INTERFACE_INDEX)) is not None: interface = get_spi_interface(index) if interface == spi_host: raise cv.Invalid( - f"`spi` component is using interface '{interface}'. " - f"To use {config[CONF_TYPE]}, you must change the `interface` on the `spi` component.", + f"The `ethernet` and `spi` components are both using interface '{interface}'. " + f"To use {config[CONF_TYPE]}, change the `interface` on either `ethernet:` or `spi:`." ) @@ -528,6 +541,8 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: cg.add(var.set_clock_speed(config[CONF_CLOCK_SPEED])) cg.add_define("USE_ETHERNET_SPI") + + cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 # ENC28J60 was never built-in to IDF, so it has no Kconfig option diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index c6e37d01ea..b760ba2af7 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -11,6 +11,9 @@ #ifdef USE_ESP32 #include "esp_eth.h" +#ifdef USE_ETHERNET_SPI +#include "hal/spi_types.h" +#endif #include "esp_eth_mac.h" #include "esp_eth_mac_esp.h" #include "esp_netif.h" @@ -135,6 +138,7 @@ class EthernetComponent final : public Component { void set_interrupt_pin(uint8_t interrupt_pin); void set_reset_pin(uint8_t reset_pin); void set_clock_speed(int clock_speed); + void set_interface(spi_host_device_t interface); #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT void set_polling_interval(uint32_t polling_interval); #endif @@ -201,6 +205,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; + spi_host_device_t interface_{SPI3_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index a170239e03..d4585bf100 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -158,12 +158,7 @@ void EthernetComponent::setup() { .intr_flags = 0, }; -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) - auto host = SPI2_HOST; -#else - auto host = SPI3_HOST; -#endif + auto host = this->interface_; err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); @@ -458,6 +453,11 @@ void EthernetComponent::dump_config() { " MOSI Pin: %u\n" " CS Pin: %u", this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); + const char *spi_interface = "spi3"; + if (this->interface_ == SPI2_HOST) { + spi_interface = "spi2"; + } + ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface); #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT if (this->polling_interval_ != 0) { ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_); @@ -760,6 +760,7 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } +void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; } #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } #endif diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index eeff98cb6e..ddb9e63686 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -28,7 +28,10 @@ namespace esphome::gpio_expander { template 256), uint16_t, uint8_t>::type> class CachedGpioExpander { public: - /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. + /// @brief Read the state of the given pin. + /// By default, each read invalidates the pin's cache entry so the next read + /// of the same pin triggers a fresh hardware read. When invalidate_on_read + /// is disabled, the cache stays valid until explicitly cleared via reset_pin_cache_(). /// @param pin Pin number to read /// @return Pin state bool digital_read(P pin) { @@ -36,14 +39,17 @@ class CachedGpioExpander { const T pin_mask = (1 << (pin % BANK_SIZE)); // Check if specific pin cache is valid if (this->read_cache_valid_[bank] & pin_mask) { - // Invalidate pin - this->read_cache_valid_[bank] &= ~pin_mask; + if (this->invalidate_on_read_) { + // Invalidate pin so next read triggers hardware read + this->read_cache_valid_[bank] &= ~pin_mask; + } } else { // Read whole bank from hardware if (!this->digital_read_hw(pin)) return false; // Mark bank cache as valid except the pin that is being returned now - this->read_cache_valid_[bank] = std::numeric_limits::max() & ~pin_mask; + // (when not invalidating on read, mark all pins including this one as valid) + this->read_cache_valid_[bank] = std::numeric_limits::max() & ~(this->invalidate_on_read_ ? pin_mask : 0); } return this->digital_read_cache(pin); } @@ -71,12 +77,18 @@ class CachedGpioExpander { /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } + /// @brief Control whether digital_read() invalidates the pin's cache entry after reading. + /// When enabled (default), each read self-invalidates so the next read triggers a hardware read. + /// When disabled, cache stays valid until reset_pin_cache_() is explicitly called. + void set_invalidate_on_read_(bool invalidate) { this->invalidate_on_read_ = invalidate; } + static constexpr uint16_t BITS_PER_BYTE = 8; static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; static constexpr size_t BANKS = N / BANK_SIZE; static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); T read_cache_valid_[BANKS]{0}; + bool invalidate_on_read_{true}; }; } // namespace esphome::gpio_expander diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index d0fd697d8f..22f292e47e 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -73,13 +73,13 @@ void HLW8012Component::update() { // Only read cf1 after one cycle. Apparently it's quite unstable after being changed. if (this->current_mode_) { float current = cf1_hz * this->current_multiplier_; - ESP_LOGD(TAG, "Got power=%.1fW, current=%.1fA", power, current); + ESP_LOGV(TAG, "Got power=%.1fW, current=%.1fA", power, current); if (this->current_sensor_ != nullptr) { this->current_sensor_->publish_state(current); } } else { float voltage = cf1_hz * this->voltage_multiplier_; - ESP_LOGD(TAG, "Got power=%.1fW, voltage=%.1fV", power, voltage); + ESP_LOGV(TAG, "Got power=%.1fW, voltage=%.1fV", power, voltage); if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(voltage); } diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index e69de29bb2..84888bbabc 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -0,0 +1,4 @@ +DEPRECATED_COMPONENT = """ +The 'ili9xxx' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 185a74fa41..1f20b21a0e 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -219,6 +219,9 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config): + LOGGER.warning( + "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) diff --git a/esphome/components/internal_temperature/internal_temperature_ln882x.cpp b/esphome/components/internal_temperature/internal_temperature_ln882x.cpp new file mode 100644 index 0000000000..621fbed030 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_ln882x.cpp @@ -0,0 +1,24 @@ +#ifdef USE_LN882X + +#include "internal_temperature.h" + +extern "C" { +uint16_t hal_adc_get_data(uint32_t adc_base, uint32_t ch); +} + +namespace esphome::internal_temperature { + +void InternalTemperatureSensor::update() { + static constexpr uint32_t ADC_BASE = 0x40000800U; + static constexpr uint32_t ADC_CH0 = 1U; + static constexpr uint16_t ADC_MASK = 0xFFF; + static constexpr float ADC_TEMP_SCALE = 2.54f; + static constexpr float ADC_TEMP_OFFSET = 278.15f; + uint16_t raw = hal_adc_get_data(ADC_BASE, ADC_CH0); + float temperature = (raw & ADC_MASK) / ADC_TEMP_SCALE - ADC_TEMP_OFFSET; + this->publish_state(temperature); +} + +} // namespace esphome::internal_temperature + +#endif // USE_LN882X diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 6d79e08675..02730b6862 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, PLATFORM_BK72XX, PLATFORM_ESP32, + PLATFORM_LN882X, PLATFORM_NRF52, PLATFORM_RP2040, STATE_CLASS_MEASUREMENT, @@ -30,7 +31,15 @@ CONFIG_SCHEMA = cv.All( state_class=STATE_CLASS_MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.polling_component_schema("60s")), - cv.only_on([PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_NRF52]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_RP2040, + PLATFORM_BK72XX, + PLATFORM_NRF52, + PLATFORM_LN882X, + ] + ), ) @@ -53,6 +62,9 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "internal_temperature_bk72xx.cpp": { PlatformFramework.BK72XX_ARDUINO, }, + "internal_temperature_ln882x.cpp": { + PlatformFramework.LN882X_ARDUINO, + }, "internal_temperature_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index b9507e707a..5797b03ba7 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -1,5 +1,9 @@ #ifdef USE_ESP8266 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_ESP8266_CRASH_HANDLER +#include "esphome/components/esp8266/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -26,6 +30,9 @@ void Logger::pre_setup() { global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP8266_CRASH_HANDLER + esp8266::crash_handler_log(); +#endif } const LogString *Logger::get_uart_selection_() { diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 3b4f150699..a9d31d42d8 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -392,6 +392,9 @@ async def to_code(configs): } & styles_used: df.add_define("LV_COLOR_SCREEN_TRANSP", "1") + if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): + df.add_define("LV_THEME_DEFAULT_DARK", "1") + # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending lv_image_formats = {"RGB565", "ARGB8888"} if { @@ -459,8 +462,11 @@ def add_hello_world(config): def _theme_schema(value): return cv.Schema( { - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() + cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, + **{ + cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) + for name, w in WIDGET_TYPES.items() + }, } )(value) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 500ccb608a..668bb46515 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -598,6 +598,7 @@ CONF_FLEX_ALIGN_CROSS = "flex_align_cross" CONF_FLEX_ALIGN_TRACK = "flex_align_track" CONF_FLEX_GROW = "flex_grow" CONF_FREEZE = "freeze" +CONF_DARK_MODE = "dark_mode" CONF_FULL_REFRESH = "full_refresh" CONF_GRADIENTS = "gradients" CONF_GRID_CELL_ROW_POS = "grid_cell_row_pos" diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml index 4af179a589..bbbd34e30a 100644 --- a/esphome/components/lvgl/hello_world.yaml +++ b/esphome/components/lvgl/hello_world.yaml @@ -3,7 +3,7 @@ - obj: id: hello_world_card_ pad_all: 12 - bg_color: white + bg_opa: cover height: 100% width: 100% scrollable: false diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 793290de73..c17f30383b 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -7,7 +7,7 @@ from esphome.core import ID from .defines import CONF_STYLE_DEFINITIONS, CONF_THEME, LValidator, literal from .helpers import add_lv_use from .lvcode import LambdaContext, lv -from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, remap_property +from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property from .types import ObjUpdateAction, lv_style_t from .widgets import collect_parts, theme_widget_map, wait_for_widgets @@ -85,7 +85,7 @@ async def style_update_to_code(config, action_id, template_arg, args): async def theme_to_code(config): if theme := config.get(CONF_THEME): add_lv_use(CONF_THEME) - for w_name, style in theme.items(): + for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES): # Work around Python 3.10 bug with nested async comprehensions # With Python 3.11 this could be simplified # TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions diff --git a/esphome/components/mcp23008/mcp23008.cpp b/esphome/components/mcp23008/mcp23008.cpp index 64b120daa4..5f73e03f6f 100644 --- a/esphome/components/mcp23008/mcp23008.cpp +++ b/esphome/components/mcp23008/mcp23008.cpp @@ -22,9 +22,14 @@ void MCP23008::setup() { // enable open-drain interrupt pins, 3.3V-safe this->write_reg(mcp23x08_base::MCP23X08_IOCON, iocon | IOCON_ODR); } + + this->setup_interrupt_pin_(); } -void MCP23008::dump_config() { ESP_LOGCONFIG(TAG, "MCP23008:"); } +void MCP23008::dump_config() { + ESP_LOGCONFIG(TAG, "MCP23008:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +} bool MCP23008::read_reg(uint8_t reg, uint8_t *value) { if (this->is_failed()) diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index e14e317d44..212c15ccf2 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -6,7 +6,8 @@ namespace mcp23017 { static const char *const TAG = "mcp23017"; -static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin +static constexpr uint8_t IOCON_MIRROR = 0x40; // Mirror INTA/INTB pins +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin void MCP23017::setup() { uint8_t iocon; @@ -19,14 +20,26 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + uint8_t iocon_flags = 0; if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon | IOCON_ODR); - this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon | IOCON_ODR); + iocon_flags |= IOCON_ODR; } + if (this->interrupt_pin_ != nullptr) { + // Mirror INTA/INTB so either pin fires for changes on any port + iocon_flags |= IOCON_MIRROR; + } + if (iocon_flags != 0) { + this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon | iocon_flags); + this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon | iocon_flags); + } + + this->setup_interrupt_pin_(); } -void MCP23017::dump_config() { ESP_LOGCONFIG(TAG, "MCP23017:"); } +void MCP23017::dump_config() { + ESP_LOGCONFIG(TAG, "MCP23017:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +} bool MCP23017::read_reg(uint8_t reg, uint8_t *value) { if (this->is_failed()) diff --git a/esphome/components/mcp23s08/mcp23s08.cpp b/esphome/components/mcp23s08/mcp23s08.cpp index 1c17b66637..983c1aa600 100644 --- a/esphome/components/mcp23s08/mcp23s08.cpp +++ b/esphome/components/mcp23s08/mcp23s08.cpp @@ -34,11 +34,14 @@ void MCP23S08::setup() { // enable open-drain interrupt pins, 3.3V-safe (addressed, only this chip) this->write_reg(mcp23x08_base::MCP23X08_IOCON, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); } + + this->setup_interrupt_pin_(); } void MCP23S08::dump_config() { ESP_LOGCONFIG(TAG, "MCP23S08:"); LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); } bool MCP23S08::read_reg(uint8_t reg, uint8_t *value) { diff --git a/esphome/components/mcp23s17/mcp23s17.cpp b/esphome/components/mcp23s17/mcp23s17.cpp index c6abd7ad59..db9a34e230 100644 --- a/esphome/components/mcp23s17/mcp23s17.cpp +++ b/esphome/components/mcp23s17/mcp23s17.cpp @@ -7,9 +7,10 @@ namespace mcp23s17 { static const char *const TAG = "mcp23s17"; // IOCON register bits -static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode -static constexpr uint8_t IOCON_HAEN = 0x08; // Hardware address enable -static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin +static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode +static constexpr uint8_t IOCON_MIRROR = 0x40; // Mirror INTA/INTB pins +static constexpr uint8_t IOCON_HAEN = 0x08; // Hardware address enable +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin void MCP23S17::set_device_address(uint8_t device_addr) { if (device_addr != 0) { @@ -37,16 +38,26 @@ void MCP23S17::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + uint8_t iocon_flags = IOCON_SEQOP | IOCON_HAEN; if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe (addressed, only this chip) - this->write_reg(mcp23x17_base::MCP23X17_IOCONA, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); - this->write_reg(mcp23x17_base::MCP23X17_IOCONB, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); + iocon_flags |= IOCON_ODR; } + if (this->interrupt_pin_ != nullptr) { + // Mirror INTA/INTB so either pin fires for changes on any port + iocon_flags |= IOCON_MIRROR; + } + if (this->open_drain_ints_ || this->interrupt_pin_ != nullptr) { + this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon_flags); + this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon_flags); + } + + this->setup_interrupt_pin_(); } void MCP23S17::dump_config() { ESP_LOGCONFIG(TAG, "MCP23S17:"); LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); } bool MCP23S17::read_reg(uint8_t reg, uint8_t *value) { diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 92228be62c..197b739204 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -32,6 +32,16 @@ void MCP23X08Base::pin_mode(uint8_t pin, gpio::Flags flags) { } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg(pin, false, iodir); } + // When interrupt_pin is configured, auto-enable CHANGE interrupt for input pins + // so the chip's INT output fires on any input state change + if (this->interrupt_pin_ != nullptr && (flags & gpio::FLAG_INPUT)) { + this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); + } + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr && (flags & gpio::FLAG_INPUT)) { + this->enable_loop(); + } } void MCP23X08Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) { diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 6f95ee98fd..efed7f5f17 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -44,6 +44,16 @@ void MCP23X17Base::pin_mode(uint8_t pin, gpio::Flags flags) { } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg(pin, false, iodir); } + // When interrupt_pin is configured, auto-enable CHANGE interrupt for input pins + // so the chip's INT output fires on any input state change + if (this->interrupt_pin_ != nullptr && (flags & gpio::FLAG_INPUT)) { + this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); + } + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr && (flags & gpio::FLAG_INPUT)) { + this->enable_loop(); + } } void MCP23X17Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) { diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index d6e82101ad..cd952099c0 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -5,6 +5,7 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -32,6 +33,7 @@ MCP23XXX_INTERRUPT_MODES = { MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ).extend(cv.COMPONENT_SCHEMA) @@ -43,6 +45,8 @@ async def register_mcp23xxx(config, num_pins): await cg.register_component(var, config) CORE.data.setdefault(CONF_MCP23XXX, {})[id.id] = num_pins cg.add(var.set_open_drain_ints(config[CONF_OPEN_DRAIN_INTERRUPT])) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) return var diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index 535119fc5c..4c1daac562 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -7,7 +7,12 @@ namespace mcp23xxx_base { template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); - this->parent_->pin_interrupt_mode(this->pin_, this->interrupt_mode_); + // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE + // interrupt for input pins, so skip the explicit call if the user didn't + // override the default (NO_INTERRUPT) + if (this->interrupt_mode_ != MCP23XXX_NO_INTERRUPT || this->parent_->get_interrupt_pin() == nullptr) { + this->parent_->pin_interrupt_mode(this->pin_, this->interrupt_mode_); + } } template void MCP23XXXGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } template bool MCP23XXXGPIOPin::digital_read() { diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index fb992466d5..6efd04e246 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -15,11 +15,34 @@ template class MCP23XXXBase : public Component, public gpio_expander: virtual void pin_interrupt_mode(uint8_t pin, MCP23XXXInterruptMode interrupt_mode); void set_open_drain_ints(const bool value) { this->open_drain_ints_ = value; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + InternalGPIOPin *get_interrupt_pin() const { return this->interrupt_pin_; } float get_setup_priority() const override { return setup_priority::IO; } - void loop() override { this->reset_pin_cache_(); } + void loop() override { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } + } protected: + // No need to clear latched interrupts before attaching the ISR — if INT is + // already low the ISR fires immediately, loop runs, cache invalidates, and + // the GPIO read clears the latch. One harmless extra read at most. + void setup_interrupt_pin_() { + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); + } + static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); } + // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; // write a value to a given register @@ -28,6 +51,7 @@ template class MCP23XXXBase : public Component, public gpio_expander: virtual void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) = 0; bool open_drain_ints_; + InternalGPIOPin *interrupt_pin_{nullptr}; }; template class MCP23XXXGPIOPin : public GPIOPin { diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 6aa98e3f66..42c7ec2224 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -279,6 +279,10 @@ def _final_validate(config): from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + if config[CONF_BUS_MODE] == TYPE_SINGLE: + spi.final_validate_device_schema(DOMAIN, require_miso=False, require_mosi=True)( + config + ) if not requires_buffer(config) and LVGL_DOMAIN not in global_config: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True @@ -286,7 +290,7 @@ def _final_validate(config): if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): - return config # No buffer needed, so no need to set a buffer size + return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) width, height, _offset_width, _offset_height = model.get_dimensions(config) @@ -298,8 +302,6 @@ def _final_validate(config): x for x in range(2, 17) if fraction >= 1 / x ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 35ab405b2d..0bce8da1ad 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,7 +1,331 @@ +#include +#include #include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.driver"; +static constexpr uint32_t WRITE_TIMEOUT_MS = 2000; + +static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; +static constexpr size_t HEADER_LEN = 5; +static constexpr uint8_t PREAMBLE = 0xFC; +static constexpr uint8_t HEADER_BYTE_1 = 0x01; +static constexpr uint8_t HEADER_BYTE_2 = 0x30; + +static constexpr uint8_t PACKET_TYPE_CONNECT_REQUEST = 0x5A; +static constexpr uint8_t PACKET_TYPE_CONNECT_RESPONSE = 0x7A; +static constexpr std::array CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01}; + +static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42; +static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62; +static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02; +static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; +static constexpr std::array STATUS_MSG_TYPES = {STATUS_MSG_SETTINGS, STATUS_MSG_ROOM_TEMP}; + +static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { + return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); +} + +template +static constexpr auto make_packet(uint8_t type, const std::array &payload) { + const size_t full_len = PayloadSize + HEADER_LEN + 1; + std::array packet{PREAMBLE, type, HEADER_BYTE_1, HEADER_BYTE_2, static_cast(PayloadSize)}; + std::copy_n(payload.begin(), PayloadSize, packet.begin() + HEADER_LEN); + packet.back() = checksum(packet.data(), packet.size() - 1); + return packet; +} + +static float decode_temperature(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; +} + +static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, CONNECT_REQUEST_PAYLOAD); + +void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); } + +bool MitsubishiCN105::update() { + if (const auto start = this->status_update_start_ms_; + start && (get_loop_time_ms() - *start) >= this->update_interval_ms_) { + this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS); + return false; + } + + if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) { + this->write_timeout_start_ms_.reset(); + this->read_pos_ = 0; + this->set_state_(State::READ_TIMEOUT); + return false; + } + + return this->read_incoming_bytes_(); +} + +void MitsubishiCN105::set_state_(State new_state) { + if (should_transition(this->state_, new_state)) { + ESP_LOGV(TAG, "Did transition: %s -> %s", LOG_STR_ARG(state_to_string(this->state_)), + LOG_STR_ARG(state_to_string(new_state))); + this->state_ = new_state; + this->did_transition_(new_state); + } else { + ESP_LOGV(TAG, "Ignoring unexpected transition %s -> %s", LOG_STR_ARG(state_to_string(this->state_)), + LOG_STR_ARG(state_to_string(new_state))); + } +} + +bool MitsubishiCN105::should_transition(State from, State to) { + switch (to) { + case State::CONNECTING: + return from == State::NOT_CONNECTED || from == State::READ_TIMEOUT; + + case State::CONNECTED: + return from == State::CONNECTING; + + case State::UPDATING_STATUS: + return from == State::CONNECTED || from == State::STATUS_UPDATED || + from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE; + + case State::STATUS_UPDATED: + return from == State::UPDATING_STATUS; + + case State::SCHEDULE_NEXT_STATUS_UPDATE: + return from == State::STATUS_UPDATED; + + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: + return from == State::SCHEDULE_NEXT_STATUS_UPDATE; + + case State::READ_TIMEOUT: + return from == State::UPDATING_STATUS || from == State::CONNECTING; + + default: + return false; + } +} + +void MitsubishiCN105::did_transition_(State to) { + switch (to) { + case State::CONNECTING: + this->send_packet_(CONNECT_PACKET); + break; + + case State::CONNECTED: + this->write_timeout_start_ms_.reset(); + this->status_msg_index_ = 0; + this->set_state_(State::UPDATING_STATUS); + break; + + case State::UPDATING_STATUS: + this->update_status_(); + break; + + case State::STATUS_UPDATED: { + this->write_timeout_start_ms_.reset(); + if (++this->status_msg_index_ >= STATUS_MSG_TYPES.size()) { + this->status_msg_index_ = 0; + } + if (this->status_msg_index_ != 0) { + this->set_state_(State::UPDATING_STATUS); + } else { + this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); + } + break; + } + + case State::SCHEDULE_NEXT_STATUS_UPDATE: + this->status_update_start_ms_ = get_loop_time_ms(); + this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + break; + + case State::READ_TIMEOUT: + this->set_state_(State::CONNECTING); + break; + + default: + break; + } +} + +void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { + dump_buffer_vv("TX", packet, len); + this->device_.write_array(packet, len); + this->write_timeout_start_ms_ = get_loop_time_ms(); +} + +void MitsubishiCN105::update_status_() { + ESP_LOGV(TAG, "Requesting status update, index=%u", this->status_msg_index_); + std::array payload = {STATUS_MSG_TYPES[this->status_msg_index_]}; + this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); +} + +void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) { + this->status_update_start_ms_.reset(); + this->set_state_(state); +} + +bool MitsubishiCN105::read_incoming_bytes_() { + uint8_t watchdog = 64; + while (this->device_.available() > 0 && watchdog-- > 0) { + uint8_t &value = this->read_buffer_[this->read_pos_]; + if (!this->device_.read_byte(&value)) { + ESP_LOGW(TAG, "UART read failed while data available"); + return false; + } + + switch (++this->read_pos_) { + case 1: + if (value != PREAMBLE) { + this->reset_read_position_and_dump_buffer_("RX ignoring preamble"); + } + continue; + + case 2: + continue; + + case 3: + if (value != HEADER_BYTE_1) { + this->reset_read_position_and_dump_buffer_("RX invalid: header 1 mismatch"); + } + continue; + + case 4: + if (value != HEADER_BYTE_2) { + this->reset_read_position_and_dump_buffer_("RX invalid: header 2 mismatch"); + } + continue; + + case HEADER_LEN: + static_assert(READ_BUFFER_SIZE > HEADER_LEN); + if (this->read_buffer_[HEADER_LEN - 1] >= READ_BUFFER_SIZE - HEADER_LEN) { + this->reset_read_position_and_dump_buffer_("RX invalid: payload too large"); + } + continue; + + default: + break; + } + + const size_t len_without_checksum = HEADER_LEN + static_cast(this->read_buffer_[HEADER_LEN - 1]); + if (this->read_pos_ <= len_without_checksum) { + continue; + } + + if (checksum(this->read_buffer_, len_without_checksum) != value) { + this->reset_read_position_and_dump_buffer_("RX invalid: checksum mismatch"); + continue; + } + + bool processed = this->process_rx_packet_(this->read_buffer_[1], this->read_buffer_ + HEADER_LEN, + len_without_checksum - HEADER_LEN); + this->reset_read_position_and_dump_buffer_("RX"); + return processed; + } + + return false; +} + +bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) { + switch (type) { + case PACKET_TYPE_CONNECT_RESPONSE: + this->set_state_(State::CONNECTED); + return false; + + case PACKET_TYPE_STATUS_RESPONSE: + return this->process_status_packet_(payload, len); + + default: + ESP_LOGVV(TAG, "RX unknown packet type 0x%02X", type); + return false; + } +} + +bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) { + if (len == 0) { + ESP_LOGVV(TAG, "RX status packet too short"); + return false; + } + + const auto previous = this->status_; + const auto msg_type = payload[0]; + if (!this->parse_status_payload_(msg_type, payload + 1, len - 1)) { + return false; + } + + if (msg_type == STATUS_MSG_TYPES[this->status_msg_index_]) { + this->set_state_(State::STATUS_UPDATED); + } + + return previous != this->status_ && this->is_status_initialized(); +} + +bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + switch (msg_type) { + case STATUS_MSG_SETTINGS: + return this->parse_status_settings_(payload, len); + + case STATUS_MSG_ROOM_TEMP: + return this->parse_status_room_temperature_(payload, len); + + default: + ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); + return false; + } +} + +bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { + if (len <= 10) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + + this->status_.power_on = payload[2] != 0; + this->status_.target_temperature = decode_temperature(-payload[4], payload[10], 31); + + return true; +} + +bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) { + if (len <= 5) { + ESP_LOGVV(TAG, "RX room temperature payload too short"); + return false; + } + + this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); + return true; +} + +void MitsubishiCN105::reset_read_position_and_dump_buffer_(const char *prefix) { + dump_buffer_vv(prefix, this->read_buffer_, this->read_pos_); + this->read_pos_ = 0; +} + +void MitsubishiCN105::dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char buf[format_hex_pretty_size(READ_BUFFER_SIZE)]; + ESP_LOGVV(TAG, "%s (%zu): %s", prefix, len, format_hex_pretty_to(buf, data, len)); +#endif +} + +const LogString *MitsubishiCN105::state_to_string(State state) { + switch (state) { + case State::NOT_CONNECTED: + return LOG_STR("Not connected"); + case State::CONNECTING: + return LOG_STR("Connecting"); + case State::CONNECTED: + return LOG_STR("Connected"); + case State::UPDATING_STATUS: + return LOG_STR("UpdatingStatus"); + case State::STATUS_UPDATED: + return LOG_STR("StatusUpdated"); + case State::SCHEDULE_NEXT_STATUS_UPDATE: + return LOG_STR("ScheduleNextStatusUpdate"); + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: + return LOG_STR("WaitingForScheduledStatusUpdate"); + case State::READ_TIMEOUT: + return LOG_STR("ReadTimeout"); + } + return LOG_STR("Unknown"); +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 6018dddbff..d43904b313 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,19 +1,74 @@ #pragma once +#include #include "esphome/components/uart/uart.h" namespace esphome::mitsubishi_cn105 { +uint32_t get_loop_time_ms(); + class MitsubishiCN105 { public: + struct Status { + bool operator==(const Status &) const = default; + + bool power_on{false}; + float target_temperature{NAN}; + float room_temperature{NAN}; + }; + explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} + void initialize(); + bool update(); + uint32_t get_update_interval() const { return this->update_interval_ms_; } void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } + const Status &status() const { return this->status_; } + bool is_status_initialized() const { return !std::isnan(status_.room_temperature); } + protected: + enum class State : uint8_t { + NOT_CONNECTED, + CONNECTING, + CONNECTED, + UPDATING_STATUS, + STATUS_UPDATED, + SCHEDULE_NEXT_STATUS_UPDATE, + WAITING_FOR_SCHEDULED_STATUS_UPDATE, + READ_TIMEOUT + }; + + void set_state_(State new_state); + void did_transition_(State to); + bool read_incoming_bytes_(); + bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); + bool process_status_packet_(const uint8_t *payload, size_t len); + bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); + bool parse_status_settings_(const uint8_t *payload, size_t len); + bool parse_status_room_temperature_(const uint8_t *payload, size_t len); + void reset_read_position_and_dump_buffer_(const char *prefix); + void send_packet_(const uint8_t *packet, size_t len); + void update_status_(); + void cancel_waiting_and_transition_to_(State state); + template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } + static bool should_transition(State from, State to); + static const LogString *state_to_string(State state); + static void dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len); + uart::UARTDevice &device_; uint32_t update_interval_ms_{1000}; + std::optional write_timeout_start_ms_; + std::optional status_update_start_ms_; + Status status_{}; + State state_{State::NOT_CONNECTED}; + uint8_t status_msg_index_{0}; + + private: + static constexpr size_t READ_BUFFER_SIZE = 32; + uint8_t read_buffer_[READ_BUFFER_SIZE]; + uint8_t read_pos_{0}; }; } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 6d50296c8f..55fc23c449 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -1,3 +1,4 @@ +#include #include "mitsubishi_cn105_climate.h" #include "esphome/core/log.h" @@ -14,15 +15,36 @@ void MitsubishiCN105Climate::dump_config() { LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); } -void MitsubishiCN105Climate::setup() {} +void MitsubishiCN105Climate::setup() { this->hp_.initialize(); } -void MitsubishiCN105Climate::loop() {} +void MitsubishiCN105Climate::loop() { + if (this->hp_.update()) { + this->apply_values_(); + } +} climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; + + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + + traits.set_visual_min_temperature(16.0f); + traits.set_visual_max_temperature(31.0f); + traits.set_visual_temperature_step(1.0f); + traits.set_visual_current_temperature_step(0.5f); + return traits; } void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {} +void MitsubishiCN105Climate::apply_values_() { + const auto &status = this->hp_.status(); + + this->target_temperature = status.target_temperature; + this->current_temperature = status.room_temperature; + + this->publish_state(); +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index 08b482025f..da8f8d8d0a 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -21,6 +21,8 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); } protected: + void apply_values_(); + MitsubishiCN105 hp_; }; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp new file mode 100644 index 0000000000..0f3fcb5648 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp @@ -0,0 +1,7 @@ +#include "esphome/core/application.h" + +namespace esphome::mitsubishi_cn105 { + +uint32_t __attribute__((weak)) get_loop_time_ms() { return App.get_loop_component_start_time(); } + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 3c4ceaf62d..5c3b39c954 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -376,7 +376,7 @@ size_t ModbusController::create_register_ranges_() { while (ix != this->sensorset_.end()) { SensorItem *curr = *ix; - ESP_LOGV(TAG, "Register: 0x%X %d %d %d offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, + ESP_LOGV(TAG, "Register: 0x%X %d %d %zu offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, curr->offset, curr->get_register_size(), curr->offset, curr->skip_updates, curr); if (r.register_count == 0) { @@ -484,18 +484,18 @@ void ModbusController::dump_config() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGCONFIG(TAG, "sensormap"); for (auto &it : this->sensorset_) { - ESP_LOGCONFIG(TAG, " Sensor type=%zu start=0x%X offset=0x%X count=%d size=%d", + ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X count=%d size=%zu", static_cast(it->register_type), it->start_address, it->offset, it->register_count, it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); for (auto &it : this->register_ranges_) { - ESP_LOGCONFIG(TAG, " Range type=%zu start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), + ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), it.start_address, it.register_count, it.skip_updates); } ESP_LOGCONFIG(TAG, "server registers"); for (auto &r : this->server_registers_) { - ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%zu register_count=%u", r->address, + ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } #endif @@ -524,7 +524,7 @@ void ModbusController::on_write_register_response(ModbusRegisterType register_ty void ModbusController::dump_sensors_() { ESP_LOGV(TAG, "sensors"); for (auto &it : this->sensorset_) { - ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%d offset=%d", it->start_address, it->register_count, + ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%zu offset=%d", it->start_address, it->register_count, it->get_register_size(), it->offset); } } diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index db79370bb3..bd9c481901 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -37,6 +37,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A, OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B, OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, + OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index efaf810ca3..598fce1562 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -3,6 +3,7 @@ #include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" +#include "esphome/core/log.h" #include #include @@ -10,6 +11,8 @@ namespace esphome::ota { +static const char *const TAG = "ota.idf"; + std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size) { @@ -98,7 +101,12 @@ OTAResponseTypes IDFOTABackend::end() { } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { +#ifdef USE_OTA_SIGNED_VERIFICATION + ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; +#else return OTA_RESPONSE_ERROR_UPDATE_END; +#endif } if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 626b08a378..99b812b33b 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -29,6 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -43,6 +45,8 @@ async def to_code(config): cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index adc7bc0fb5..ac4f119dfe 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -34,12 +34,26 @@ void PCA9554Component::setup() { this->read_inputs_(); ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), this->status_has_error()); -} + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9554Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read — only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); +} +void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_loop_soon_any_context(); } void PCA9554Component::loop() { - // Invalidate the cache at the start of each loop. - // The actual read will happen on demand when digital_read() is called + // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + // Interrupt-driven: disable loop until next interrupt fires + this->disable_loop(); + } } void PCA9554Component::dump_config() { @@ -47,6 +61,7 @@ void PCA9554Component::dump_config() { "PCA9554:\n" " I/O Pins: %d", this->pin_count_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -76,6 +91,11 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { // Clear mode mask bit this->config_mask_ &= ~(1 << pin); + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { // Set mode mask bit this->config_mask_ |= 1 << pin; diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 1d877f9ce2..f33f9d4592 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -16,7 +16,6 @@ class PCA9554Component : public Component, /// Check i2c availability and setup masks void setup() override; - /// Invalidate cache at start of each loop void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -26,8 +25,11 @@ class PCA9554Component : public Component, void dump_config() override; void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: + static void IRAM_ATTR gpio_intr(PCA9554Component *arg); + bool read_inputs_(); bool write_register_(uint8_t reg, uint16_t value); @@ -48,6 +50,7 @@ class PCA9554Component : public Component, uint16_t input_mask_{0x00}; /// Storage for last I2C error seen esphome::i2c::ErrorCode last_error_; + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index f387d0a610..902efd2279 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -27,6 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -39,6 +41,8 @@ async def to_code(config): await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index d3ec31436d..bf4a9442a2 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -15,16 +15,33 @@ void PCF8574Component::setup() { this->write_gpio_(); this->read_gpio_(); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCF8574Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read — only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); } +void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_loop_soon_any_context(); } void PCF8574Component::loop() { - // Invalidate the cache at the start of each loop + // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + // Interrupt-driven: disable loop until next interrupt fires + this->disable_loop(); + } } void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:\n" " Is PCF8575: %s", YESNO(this->pcf8575_)); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -51,6 +68,11 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { this->mode_mask_ &= ~(1 << pin); // Write GPIO to enable input mode this->write_gpio_(); + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { // Set mode mask bit this->mode_mask_ |= 1 << pin; diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index b039173789..cae2e930b7 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -17,10 +17,10 @@ class PCF8574Component : public Component, PCF8574Component() = default; void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks void setup() override; - /// Invalidate cache at start of each loop void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -30,6 +30,8 @@ class PCF8574Component : public Component, void dump_config() override; protected: + static void IRAM_ATTR gpio_intr(PCF8574Component *arg); + bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -44,6 +46,7 @@ class PCF8574Component : public Component, /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index c64f923823..d5b19dab1c 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -33,6 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -46,6 +48,8 @@ async def to_code(config): await i2c.register_i2c_device(var, config) cg.add(var.set_reset(config[CONF_RESET])) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 9247e114f0..6e8631022a 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -33,9 +33,24 @@ void PI4IOE5V6408Component::setup() { return; } } + + // No need to clear latched interrupts before attaching the ISR — if INT is + // already low the ISR fires immediately, loop runs, cache invalidates, and + // the read clears the latch. One harmless extra read at most. + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PI4IOE5V6408Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); } +void IRAM_ATTR PI4IOE5V6408Component::gpio_intr(PI4IOE5V6408Component *arg) { arg->enable_loop_soon_any_context(); } void PI4IOE5V6408Component::dump_config() { ESP_LOGCONFIG(TAG, "PI4IOE5V6408:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -55,12 +70,22 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { this->pull_up_down_mask_ &= ~(1 << pin); this->pull_enable_mask_ |= 1 << pin; } + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } // Write GPIO to enable input mode this->write_gpio_modes_(); } -void PI4IOE5V6408Component::loop() { this->reset_pin_cache_(); } +void PI4IOE5V6408Component::loop() { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } +} bool PI4IOE5V6408Component::read_gpio_outputs_() { if (this->is_failed()) @@ -142,6 +167,13 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { this->status_set_warning(LOG_STR("Failed to write GPIO pull enable")); return false; } + // Enable interrupts for input pins when interrupt pin is configured + // (input pins have mode_mask_ bit cleared) + if (this->interrupt_pin_ != nullptr && + !this->write_byte(PI4IOE5V6408_REGISTER_INTERRUPT_ENABLE_MASK, static_cast(~this->mode_mask_))) { + this->status_set_warning(LOG_STR("Failed to write interrupt enable mask")); + return false; + } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Wrote GPIO config:\n" diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 4dc31201ce..ff2474fe99 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -22,8 +22,11 @@ class PI4IOE5V6408Component : public Component, /// Indicate if the component should reset the state during setup void set_reset(bool reset) { this->reset_ = reset; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: + static void IRAM_ATTR gpio_intr(PI4IOE5V6408Component *arg); + bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -40,6 +43,7 @@ class PI4IOE5V6408Component : public Component, uint8_t pull_up_down_mask_{0x00}; bool reset_{true}; + InternalGPIOPin *interrupt_pin_{nullptr}; bool read_gpio_modes_(); bool write_gpio_modes_(); diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 2ebe67c3a5..fa42b53496 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -248,6 +248,9 @@ void RuntimeImage::release_buffer_() { this->height_ = 0; this->buffer_width_ = 0; this->buffer_height_ = 0; +#ifdef USE_LVGL + memset(&this->dsc_, 0, sizeof(this->dsc_)); +#endif } } diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 0e14371d00..9d2fb725f3 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -116,7 +116,7 @@ void SelectCall::perform() { auto idx = target_index.value(); // All operations use indices, call control() by index to avoid string conversion - ESP_LOGD(TAG, "'%s' - Set selected option to: %s", name, parent->option_at(idx)); + ESP_LOGV(TAG, "'%s' - Set selected option to: %s", name, parent->option_at(idx)); parent->control(idx); } diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index 36344a6d38..20359135ba 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -68,7 +68,7 @@ void SPIComponent::dump_config() { LOG_PIN(" SDI Pin: ", this->sdi_pin_); LOG_PIN(" SDO Pin: ", this->sdo_pin_); for (size_t i = 0; i != this->data_pins_.size(); i++) { - ESP_LOGCONFIG(TAG, " Data pin %u: GPIO%d", i, this->data_pins_[i]); + ESP_LOGCONFIG(TAG, " Data pin %zu: GPIO%d", i, this->data_pins_[i]); } if (this->spi_bus_->is_hw()) { ESP_LOGCONFIG(TAG, " Using HW SPI: %s", this->interface_name_); @@ -118,4 +118,12 @@ uint16_t SPIDelegateBitBash::transfer_(uint16_t data, size_t num_bits) { return out_data; } +#if !defined(USE_ESP32) && !defined(USE_ARDUINO) +// Stub for unsupported platforms (host, Zephyr, etc.) - hardware SPI is unavailable +SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo, GPIOPin *sdi, + const std::vector &data_pins) { + return nullptr; +} +#endif + } // namespace esphome::spi diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 84c8bca267..dc538f4c41 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -23,9 +23,9 @@ using SPIInterface = SPIClassRP2040 *; using SPIInterface = SPIClass *; #endif -#elif defined(CLANG_TIDY) +#elif defined(USE_HOST) || defined(CLANG_TIDY) -using SPIInterface = void *; // Stub for platforms without SPI (e.g., Zephyr) +using SPIInterface = void *; // Stub for platforms without SPI (e.g., host, Zephyr) #endif // USE_ESP32 / USE_ARDUINO diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index ba854bb0ae..62dfa6f413 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -1,3 +1,8 @@ import esphome.codegen as cg st7735_ns = cg.esphome_ns.namespace("st7735") + +DEPRECATED_COMPONENT = """ +The 'st7735' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 9dc69f27ff..766370c21f 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -15,6 +17,7 @@ from esphome.const import ( from . import st7735_ns CODEOWNERS = ["@SenexCrenshaw"] +LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["spi"] @@ -87,6 +90,9 @@ async def setup_st7735(var, config): async def to_code(config): + LOGGER.warning( + "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) var = cg.new_Pvariable( config[CONF_ID], config[CONF_MODEL], diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 11840db3a3..abc7338a62 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -18,15 +18,15 @@ void Switch::control(bool target_state) { } } void Switch::turn_on() { - ESP_LOGD(TAG, "'%s' Turning ON.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Turning ON.", this->get_name().c_str()); this->write_state(!this->inverted_); } void Switch::turn_off() { - ESP_LOGD(TAG, "'%s' Turning OFF.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Turning OFF.", this->get_name().c_str()); this->write_state(this->inverted_); } void Switch::toggle() { - ESP_LOGD(TAG, "'%s' Toggling %s.", this->get_name().c_str(), this->state ? "OFF" : "ON"); + ESP_LOGV(TAG, "'%s' Toggling %s.", this->get_name().c_str(), this->state ? "OFF" : "ON"); this->write_state(this->inverted_ == this->state); } optional Switch::get_initial_state() { diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index c31ccbc7ea..7ac0abeee0 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -123,8 +123,8 @@ def _parse_cron_part(part, min_value, max_value, special_mapping): f"Can't have more than two '/' in one time expression, got {part}" ) offset, repeat = data - offset_n = 0 - if offset: + offset_n = min_value + if offset and offset not in ("*", "?"): offset_n = _parse_cron_int( offset, special_mapping, diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index 8bc87878d1..7eb99cfe74 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -20,7 +20,12 @@ bool CronTrigger::matches(const ESPTime &time) { return time.is_valid() && this->seconds_[time.second] && this->minutes_[time.minute] && this->hours_[time.hour] && this->days_of_month_[time.day_of_month] && this->months_[time.month] && this->days_of_week_[time.day_of_week]; } -void CronTrigger::loop() { +void CronTrigger::setup() { + // Cron resolution is 1 second — check once per second instead of every loop iteration + this->set_interval(1000, [this]() { this->check_time_(); }); +} + +void CronTrigger::check_time_() { ESPTime time = this->rtc_->now(); if (!time.is_valid()) return; diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 4ccfc641d6..546c4a10de 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -26,10 +26,11 @@ class CronTrigger : public Trigger<>, public Component { void add_day_of_week(uint8_t day_of_week); void add_days_of_week(const std::vector &days_of_week); bool matches(const ESPTime &time); - void loop() override; + void setup() override; float get_setup_priority() const override; protected: + void check_time_(); std::bitset<61> seconds_; std::bitset<60> minutes_; std::bitset<24> hours_; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index e7a45a5edf..161c712cc1 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -1,10 +1,21 @@ #include "total_daily_energy.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace total_daily_energy { +namespace esphome::total_daily_energy { static const char *const TAG = "total_daily_energy"; +static constexpr uint32_t TIMEOUT_ID_MIDNIGHT = 1; +static constexpr uint8_t SECONDS_PER_MINUTE = 60; +static constexpr uint8_t MINUTES_PER_HOUR = 60; +static constexpr uint8_t HOURS_PER_DAY = 24; +static constexpr uint32_t SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; +static constexpr uint16_t MILLIS_PER_SECOND = 1000; +// Wake up 90 minutes before midnight to recalculate, ensuring DST transitions +// (which shift wall clock by 1 hour but don't change millis()) don't cause +// the midnight reset to fire late. DST transitions don't trigger the time sync +// callback since they change local time interpretation, not the epoch. +static constexpr uint32_t PRE_MIDNIGHT_SECONDS = 90 * SECONDS_PER_MINUTE; void TotalDailyEnergy::setup() { float initial_value = 0; @@ -15,28 +26,55 @@ void TotalDailyEnergy::setup() { } this->publish_state_and_save(initial_value); - this->last_update_ = millis(); + this->last_update_ = App.get_loop_component_start_time(); this->parent_->add_on_state_callback([this](float state) { this->process_new_state_(state); }); + + // Schedule initial midnight reset if time is already valid, otherwise + // the time sync callback will handle it once time becomes available. + this->schedule_midnight_reset_(); + // Re-schedule on every NTP sync in case the clock jumped across midnight. + this->time_->add_on_time_sync_callback([this]() { this->schedule_midnight_reset_(); }); } void TotalDailyEnergy::dump_config() { LOG_SENSOR("", "Total Daily Energy", this); } -void TotalDailyEnergy::loop() { +void TotalDailyEnergy::schedule_midnight_reset_() { auto t = this->time_->now(); if (!t.is_valid()) return; - if (this->last_day_of_year_ == 0) { + // Check if the day changed (time sync moved us past midnight, or first call) + if (this->last_day_of_year_ != t.day_of_year) { + if (this->last_day_of_year_ != 0) { + // Day actually changed — reset energy + this->total_energy_ = 0; + this->publish_state_and_save(0); + } this->last_day_of_year_ = t.day_of_year; - return; } - if (t.day_of_year != this->last_day_of_year_) { - this->last_day_of_year_ = t.day_of_year; - this->total_energy_ = 0; - this->publish_state_and_save(0); + // Calculate seconds until next midnight. + // Uses the same TIMEOUT_ID_MIDNIGHT ID so re-scheduling (e.g. from time sync) cancels + // any previously pending timeout. + uint32_t seconds_until_midnight = + ((HOURS_PER_DAY - 1 - t.hour) * MINUTES_PER_HOUR + (MINUTES_PER_HOUR - 1 - t.minute)) * SECONDS_PER_MINUTE + + (SECONDS_PER_MINUTE - t.second); + + // set_timeout counts real elapsed millis, but DST shifts wall clock by up to 1 hour + // without changing millis. To avoid firing up to 1 hour late/early, we use two stages: + // 1) Wake up 90 minutes before midnight to recalculate with current wall clock + // 2) From there, schedule the precise midnight reset + uint32_t timeout_seconds; + if (seconds_until_midnight > PRE_MIDNIGHT_SECONDS) { + timeout_seconds = seconds_until_midnight - PRE_MIDNIGHT_SECONDS; + } else { + timeout_seconds = seconds_until_midnight + 1; } + + ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds); + this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND, + [this]() { this->schedule_midnight_reset_(); }); } void TotalDailyEnergy::publish_state_and_save(float state) { @@ -50,14 +88,14 @@ void TotalDailyEnergy::publish_state_and_save(float state) { void TotalDailyEnergy::process_new_state_(float state) { if (std::isnan(state)) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); const float old_state = this->last_power_state_; const float new_state = state; - float delta_hours = (now - this->last_update_) / 1000.0f / 60.0f / 60.0f; + float delta_hours = (now - this->last_update_) / static_cast(MILLIS_PER_SECOND) / SECONDS_PER_HOUR; float delta_energy = 0.0f; switch (this->method_) { case TOTAL_DAILY_ENERGY_METHOD_TRAPEZOID: - delta_energy = delta_hours * (old_state + new_state) / 2.0; + delta_energy = delta_hours * (old_state + new_state) / 2.0f; break; case TOTAL_DAILY_ENERGY_METHOD_LEFT: delta_energy = delta_hours * old_state; @@ -71,5 +109,4 @@ void TotalDailyEnergy::process_new_state_(float state) { this->publish_state_and_save(this->total_energy_ + delta_energy); } -} // namespace total_daily_energy -} // namespace esphome +} // namespace esphome::total_daily_energy diff --git a/esphome/components/total_daily_energy/total_daily_energy.h b/esphome/components/total_daily_energy/total_daily_energy.h index 1145f54f95..9a20ecea01 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.h +++ b/esphome/components/total_daily_energy/total_daily_energy.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/time/real_time_clock.h" -namespace esphome { -namespace total_daily_energy { +namespace esphome::total_daily_energy { enum TotalDailyEnergyMethod { TOTAL_DAILY_ENERGY_METHOD_TRAPEZOID = 0, @@ -23,12 +22,12 @@ class TotalDailyEnergy : public sensor::Sensor, public Component { void set_method(TotalDailyEnergyMethod method) { method_ = method; } void setup() override; void dump_config() override; - void loop() override; void publish_state_and_save(float state); protected: void process_new_state_(float state); + void schedule_midnight_reset_(); ESPPreferenceObject pref_; time::RealTimeClock *time_; @@ -41,5 +40,4 @@ class TotalDailyEnergy : public sensor::Sensor, public Component { float last_power_state_{0.0f}; }; -} // namespace total_daily_energy -} // namespace esphome +} // namespace esphome::total_daily_energy diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1dda6204fe..a57a8d26ff 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -286,10 +286,11 @@ void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char this->send(message, event, id, reconnect); } -void DeferredUpdateEventSourceList::loop() { +bool DeferredUpdateEventSourceList::loop() { for (DeferredUpdateEventSource *dues : *this) { dues->loop(); } + return !this->empty(); } void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type, @@ -318,6 +319,7 @@ void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServer es->onDisconnect([this, es](AsyncEventSourceClient *client) { this->on_client_disconnect_(es); }); es->handleRequest(request); + ws->enable_loop_soon_any_context(); } void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource *source) { @@ -413,13 +415,24 @@ void WebServer::setup() { // doesn't need defer functionality - if the queue is full, the client JS knows it's alive because it's clearly // getting a lot of events this->set_interval(10000, [this]() { + if (this->events_.empty()) + return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, "ping", millis(), 30000); }); } -void WebServer::loop() { this->events_.loop(); } +void WebServer::loop() { + // No SSE clients connected; stop looping until a new client connects via + // enable_loop_soon_any_context(). This is safe because: + // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() + // - deferrable_send_state early-outs when no clients are connected + // - try_send_nodefer (log, ping) iterates sessions which are empty + // - REST API handlers use defer() which runs via the Scheduler + if (!this->events_.loop()) + this->disable_loop(); +} #ifdef USE_LOGGER void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6152dfbfd3..8e8b1de8c4 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -169,7 +169,8 @@ class DeferredUpdateEventSourceList final : public std::liston_connect_(rsp); } this->sessions_.push_back(rsp); + // Wake up WebServer::loop() to drain deferred event queues for this client. + // Safe from httpd task context via the pending_enable_loop_ flag. + this->web_server_->enable_loop_soon_any_context(); } -void AsyncEventSource::loop() { +bool AsyncEventSource::loop() { // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions @@ -504,6 +507,7 @@ void AsyncEventSource::loop() { ++i; } } + return !this->sessions_.empty(); } void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 81683e8d85..f2931fb507 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -340,7 +340,8 @@ class AsyncEventSource : public AsyncWebHandler { void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void loop(); + /// Returns true if there are sessions remaining (including pending cleanup). + bool loop(); bool empty() { return this->count() == 0; } size_t count() const { return this->sessions_.size(); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index faa8c6d4b0..d92fb6e98a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -211,6 +211,7 @@ #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK +#define USE_OTA_SIGNED_VERIFICATION #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_SRAM1_AS_IRAM @@ -330,6 +331,7 @@ // ESP8266-specific feature flags #ifdef USE_ESP8266 #define USE_ADC_SENSOR_VCC +#define USE_ESP8266_CRASH_HANDLER #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2) #define USE_CAPTIVE_PORTAL #define USE_ESP8266_LOGGER_SERIAL diff --git a/esphome/espota2.py b/esphome/espota2.py index c412bb51ff..4b813e4060 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -40,6 +40,8 @@ RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE = 0x88 RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE = 0x89 RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A RESPONSE_ERROR_MD5_MISMATCH = 0x8B +RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C +RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -192,6 +194,12 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "Error: Application MD5 code mismatch. Please try again " "or flash over USB with a good quality cable." ) + if dat == RESPONSE_ERROR_SIGNATURE_INVALID: + raise OTAError( + "Error: Firmware signature verification failed. The firmware was not signed " + "with the correct key. Ensure the signing key matches the one used to build " + "the firmware currently running on the device." + ) if dat == RESPONSE_ERROR_UNKNOWN: raise OTAError("Unknown error from ESP") if not isinstance(expect, (list, tuple)): diff --git a/requirements.txt b/requirements.txt index a8ec413b23..9c63cdee27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260210.0 -aioesphomeapi==44.8.1 +aioesphomeapi==44.9.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import diff --git a/script/helpers.py b/script/helpers.py index 290dcadf0b..c9c550d889 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -15,8 +15,6 @@ from typing import Any import colorama -from esphome.loader import get_platform - root_path = os.path.abspath(os.path.normpath(os.path.join(__file__, "..", ".."))) basepath = os.path.join(root_path, "esphome") temp_folder = os.path.join(root_path, ".temp") @@ -644,7 +642,7 @@ def get_all_dependencies( PLATFORM_HOST, ) from esphome.core import CORE - from esphome.loader import get_component + from esphome.loader import get_component, get_platform all_components: set[str] = set(component_names) diff --git a/tests/benchmarks/components/api/bench_list_entities.cpp b/tests/benchmarks/components/api/bench_list_entities.cpp new file mode 100644 index 0000000000..02cef50d70 --- /dev/null +++ b/tests/benchmarks/components/api/bench_list_entities.cpp @@ -0,0 +1,235 @@ +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/light/color_mode.h" + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// --- ListEntitiesSensorResponse --- + +static ListEntitiesSensorResponse make_sensor_response() { + ListEntitiesSensorResponse msg; + msg.object_id = StringRef::from_lit("living_room_temperature"); + msg.key = 0x12345678; + msg.name = StringRef::from_lit("Living Room Temperature"); +#ifdef USE_ENTITY_ICON + msg.icon = StringRef::from_lit("mdi:thermometer"); +#endif + msg.entity_category = enums::ENTITY_CATEGORY_NONE; + msg.disabled_by_default = false; + msg.unit_of_measurement = StringRef::from_lit("°C"); + msg.accuracy_decimals = 1; + msg.force_update = false; + msg.device_class = StringRef::from_lit("temperature"); + msg.state_class = enums::STATE_CLASS_MEASUREMENT; +#ifdef USE_DEVICES + msg.device_id = 1; +#endif + return msg; +} + +static void CalculateSize_ListEntitiesSensorResponse(benchmark::State &state) { + auto msg = make_sensor_response(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_ListEntitiesSensorResponse); + +static void Encode_ListEntitiesSensorResponse(benchmark::State &state) { + auto msg = make_sensor_response(); + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ListEntitiesSensorResponse); + +static void CalcAndEncode_ListEntitiesSensorResponse(benchmark::State &state) { + auto msg = make_sensor_response(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_ListEntitiesSensorResponse); + +// --- ListEntitiesBinarySensorResponse --- + +static ListEntitiesBinarySensorResponse make_binary_sensor_response() { + ListEntitiesBinarySensorResponse msg; + msg.object_id = StringRef::from_lit("front_door_contact"); + msg.key = 0xAABBCCDD; + msg.name = StringRef::from_lit("Front Door Contact"); +#ifdef USE_ENTITY_ICON + msg.icon = StringRef::from_lit("mdi:door"); +#endif + msg.entity_category = enums::ENTITY_CATEGORY_NONE; + msg.disabled_by_default = false; + msg.device_class = StringRef::from_lit("door"); + msg.is_status_binary_sensor = false; +#ifdef USE_DEVICES + msg.device_id = 2; +#endif + return msg; +} + +static void CalculateSize_ListEntitiesBinarySensorResponse(benchmark::State &state) { + auto msg = make_binary_sensor_response(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_ListEntitiesBinarySensorResponse); + +static void Encode_ListEntitiesBinarySensorResponse(benchmark::State &state) { + auto msg = make_binary_sensor_response(); + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ListEntitiesBinarySensorResponse); + +static void CalcAndEncode_ListEntitiesBinarySensorResponse(benchmark::State &state) { + auto msg = make_binary_sensor_response(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_ListEntitiesBinarySensorResponse); + +// --- ListEntitiesLightResponse --- + +static light::ColorModeMask light_color_modes; +static FixedVector light_effects; + +static ListEntitiesLightResponse make_light_response() { + // Initialize static data on first call + static bool initialized = false; + if (!initialized) { + light_color_modes.insert(light::ColorMode::RGB_WHITE); + light_color_modes.insert(light::ColorMode::COLOR_TEMPERATURE); + light_effects.init(3); + light_effects.push_back("None"); + light_effects.push_back("Rainbow"); + light_effects.push_back("Strobe"); + initialized = true; + } + + ListEntitiesLightResponse msg; + msg.object_id = StringRef::from_lit("kitchen_ceiling_light"); + msg.key = 0x55667788; + msg.name = StringRef::from_lit("Kitchen Ceiling Light"); +#ifdef USE_ENTITY_ICON + msg.icon = StringRef::from_lit("mdi:ceiling-light"); +#endif + msg.entity_category = enums::ENTITY_CATEGORY_NONE; + msg.disabled_by_default = false; + msg.supported_color_modes = &light_color_modes; + msg.min_mireds = 153.0f; + msg.max_mireds = 500.0f; + msg.effects = &light_effects; +#ifdef USE_DEVICES + msg.device_id = 3; +#endif + return msg; +} + +static void CalculateSize_ListEntitiesLightResponse(benchmark::State &state) { + auto msg = make_light_response(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_ListEntitiesLightResponse); + +static void Encode_ListEntitiesLightResponse(benchmark::State &state) { + auto msg = make_light_response(); + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ListEntitiesLightResponse); + +static void CalcAndEncode_ListEntitiesLightResponse(benchmark::State &state) { + auto msg = make_light_response(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_ListEntitiesLightResponse); + +} // namespace esphome::api::benchmarks diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 1ae8cc644e..119bbf7fea 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -133,6 +133,6 @@ def test_code_generation( assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp - assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp + assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp # assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py index eddf0987d0..082a9e55f2 100644 --- a/tests/component_tests/mipi_spi/conftest.py +++ b/tests/component_tests/mipi_spi/conftest.py @@ -1,6 +1,7 @@ """Tests for mpip_spi configuration validation.""" from collections.abc import Callable, Generator +from unittest import mock import pytest @@ -12,6 +13,16 @@ from esphome.core import CORE from esphome.pins import gpio_pin_schema +@pytest.fixture(autouse=True) +def mock_spi_final_validate(): + """Mock spi.final_validate_device_schema since unit tests have no real SPI bus config.""" + with mock.patch( + "esphome.components.spi.final_validate_device_schema", + return_value=lambda config: None, + ): + yield + + @pytest.fixture def choose_variant_with_pins() -> Generator[Callable[[list], None]]: """ diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index ab42a75694..c11c7816e4 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -25,7 +25,9 @@ from tests.component_tests.types import SetCoreConfigCallable def validated_config(config): """Run schema + final validation and return the validated config.""" - return FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config def test_metadata_native_quad_default_test_card( diff --git a/tests/components/ags10/common.yaml b/tests/components/ags10/common.yaml index 0551871e59..8009c56295 100644 --- a/tests/components/ags10/common.yaml +++ b/tests/components/ags10/common.yaml @@ -4,3 +4,14 @@ sensor: tvoc: name: AGS10 TVOC update_interval: 60s + +button: + - platform: template + name: "Test AGS10 Actions" + on_press: + - ags10.set_zero_point: + id: ags10_1 + mode: CURRENT_VALUE + - ags10.new_i2c_address: + id: ags10_1 + address: 0x1A diff --git a/tests/components/esp32/dummy_signing_key.pem b/tests/components/esp32/dummy_signing_key.pem new file mode 100644 index 0000000000..a74231d590 --- /dev/null +++ b/tests/components/esp32/dummy_signing_key.pem @@ -0,0 +1,41 @@ +*** DO NOT USE THIS KEY...EVER *** +-----BEGIN RSA PRIVATE KEY----- +MIIG5AIBAAKCAYEA0J665DlxzUzzouzH96fxqXybEfFU7H1oSf2fUHwoNMgUG7Vc +SHxuFJkpsUnxg9br09/v5THOXfUj5t/Arog6FGiL7i0HXCYDMnSn2EzQWR+DY2Qj +C3YzTLvcOQ40gFjDWfzAheAMCQmc5xQeB3YmaXQf+fUWH/PfFs9Pm+L92YTv2XC1 +B2q5s8K8hUWghO472A+UMrreuDcltNJ+TbuSRHK0NQzKpKo0Vkl4HycczGDgpa8D +h68JL/BKVeJAjKxWd/xcj/FCk661ODXi0esB/mGQP3hAthWpwi+gdkWczWs1Ocr6 +VxKje1zFm9SEq+SmCViPY/Pu8Xs7steqz3b3JtRGtKQE0r3B+hBKI7aRudOZyz0s +kqoL1zYrAWmoTWBqa2tj1ACPqtr2LyHGt2aVrHRQGJf21mPYIy9GIOv+3v3GzIAK +az2B8Z93Bw1biwNZDr1SLNYfQVaJT1hQavmdlvwW8vqLUGDcQlk42yOF6nAmvAPu +Wzxf+QFEtJT6Am65AgMBAAECggGAB0d+mG+LscDtYGI4MQNGaqZLJ+NelfjjPm+v +0yhd48eWcggQPgQ/eA8HFiVRHMtPQ7+U2I+2Fm+zDr+AcuaUdjlWppsiHlxCMMzC +vYiinXV8yWdJVMFNVXBZpRECknbmbBmmYxV3/gm8lJCOYq7D9NqFMhzT5o4FGv/l +VHhlaKVblB/7ZRSbgbL6DoFpMjI42tdiUanVEyLzeR1+JDq3BhXlhVNar8ezl04t +d5LPDa+UrxtN+XpJTQeqpFgGbhImSxjzCjo0kbGiEx/DwWuFJxguIcDU25sM4g2+ +ivtn7N11U0oaqNwsz7p4cKAm8toJYxxXWZvKdj1kZvCZ+BtyH0/MtOa2Q6v91HOh +zY4KEl5wxQYnxJrgqevSm8rrC51tLOCidZ16cHba8sjrK69xysEazk43roHLFXDp +JpH7Zd8LETjFWGVfUz6vppzkt6mrJk0DNuMLk/UwpPHzW2pu1qDiHPCb9+ra1S9U +t55hT2TBFDcG/NmZZnyHQoh8METhAoHBAPIG0G8Cd4fmkvbgCRLzCIWsH6zGHS9o +80Rj9Gu93B+m/F9GtgyYuX+DKSdMdw3IJamUsBwofT2wynmkuJFhLtD1FmYtlsXf +TWp8g8CfFGrIXDvin5E3heyhvtFiOjXlw0Q8yMmQXr5LF0i3WyFCPQM20ugClB7N +CQBOVAfpVoRU1fA6UjjHibFRwi1b4bLV69QiERPCJfcny/DPkZpu7I1fiINmwzEb +O5mIFo5F4TQADEreWplXEmhEXIzDMFIwEQKBwQDcqimCcO3RSysZMQhhUfk8G19I +yRNwvi2fK5LiGCZMYjeYKqg1rBN4yCf9PTwaqBNRqXTg13Fc7zrOkSI+0oDa4FWI +/kMEztaUK+Kwd2NKc96aXHMBGF+1Sx7Ygnr9e2dyqDqRij2/qlQYY1EDz7cYldaX +YNrXcQQeNJbqydjRDYi+9bI+wDkrK/5PxE1sGmqS1RMKxoJCZmxNiQT3PmXM/oNR +Ev6N9CDklFtClWNcD0Uum+mxNJ53ldZDx4UI/CkCgcEA6R6BI3vX0FHaGureMp9f +BQoulEdbEzBeqPAyHJkKbn50Nf0xGt78RYL7X7v6LI8tH7N1Eho5z/L6g8KSeI2H +/4MiqRaeVEdrFPeMHDvd+aC1noUBt2komS2OU7XuZb3CoHZ/3A4wA9DmQ4dAwr8/ +b1oeOZVKQISzd9T6gYhSajIgwzwZuFESInaitvf6ZDxC49hQZJyr3u05NeFo2Lyh +Iuby4cZYmnMlrBN1zmImseSd8ntL/sjslPvLvVXAtFlRAoHBAIDuG5rPiOTE2sW5 +VIAoeUuZYq8QbX9uXxGlUAkyuw3eRUVvhyD1DduAd30Ljla05bTNIjFNMDtwvBd9 +zViPfiJk+RU2GspwYAfrLGSXHTifQu1GHxwAtcsjvT4b3ujEdckUakQnVbTrPH+T +Z/6mGwEOa3e/a559tj4/0/4TOc/L7J5GyILJpZ2H8uuAcww60xI/1QRywCEz3wve +hzw/BRQlkWyJgJpIjf+Af2IEDy327iExj/WuHPkaXzrzFNQPIQKBwAM6qeNOxrO3 +V91wg4+44FAsOda62fZ0GlCM7ETnEjLbFamtCKEcDNfijwTa54LcZ6yObyutD1RN +dhj4Z6QKuYnsE02agv9CtXdFEVEXaqj4pshdgVOwGK34OidT4yIJQGLrRAQ/JiGH +x6CoGUCNIAq5J08VdosLTD9qdn1zv8USCAP0ReKnRMndTzENLYz9G3nQyHgt5GzI +YoSRtrWnXrQp2Yn3epk74gFAJtKozWNV4Du35FJBjmSeMuRivonNMQ== +-----END RSA PRIVATE KEY----- +*** DO NOT USE THIS KEY...EVER *** diff --git a/tests/components/esp32/test-signed_ota.esp32-s3-idf.yaml b/tests/components/esp32/test-signed_ota.esp32-s3-idf.yaml new file mode 100644 index 0000000000..cf1a54cfa1 --- /dev/null +++ b/tests/components/esp32/test-signed_ota.esp32-s3-idf.yaml @@ -0,0 +1,10 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key.pem + signing_scheme: rsa3072 + +<<: !include common.yaml diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index 36f1b5365f..f1551fef60 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -1 +1,20 @@ -<<: !include common-w5500.yaml +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + interface: spi2 + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/internal_temperature/test.ln882x-ard.yaml b/tests/components/internal_temperature/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/internal_temperature/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 3a6af93b64..3c5c730e6c 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -49,6 +49,7 @@ lvgl: bg_color: 0x000000 bg_opa: cover theme: + dark_mode: true obj: border_width: 1 diff --git a/tests/components/mcp23008/common.yaml b/tests/components/mcp23008/common.yaml index 4a407adfd8..7eeee409ff 100644 --- a/tests/components/mcp23008/common.yaml +++ b/tests/components/mcp23008/common.yaml @@ -1,6 +1,10 @@ mcp23008: - i2c_id: i2c_bus - id: mcp23008_hub + - i2c_id: i2c_bus + id: mcp23008_hub + - i2c_id: i2c_bus + id: mcp23008_hub_int + address: 0x21 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio @@ -9,6 +13,12 @@ binary_sensor: mcp23xxx: mcp23008_hub number: 0 mode: INPUT + - platform: gpio + id: mcp23008_binary_sensor_int + pin: + mcp23xxx: mcp23008_hub_int + number: 0 + mode: INPUT switch: - platform: gpio diff --git a/tests/components/mcp23008/test.esp32-idf.yaml b/tests/components/mcp23008/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/mcp23008/test.esp32-idf.yaml +++ b/tests/components/mcp23008/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/mcp23008/test.esp8266-ard.yaml b/tests/components/mcp23008/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/mcp23008/test.esp8266-ard.yaml +++ b/tests/components/mcp23008/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/mcp23008/test.rp2040-ard.yaml b/tests/components/mcp23008/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/mcp23008/test.rp2040-ard.yaml +++ b/tests/components/mcp23008/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/mcp23017/common.yaml b/tests/components/mcp23017/common.yaml index 54a97e911f..8b1e32600e 100644 --- a/tests/components/mcp23017/common.yaml +++ b/tests/components/mcp23017/common.yaml @@ -1,6 +1,10 @@ mcp23017: - i2c_id: i2c_bus - id: mcp23017_hub + - i2c_id: i2c_bus + id: mcp23017_hub + - i2c_id: i2c_bus + id: mcp23017_hub_int + address: 0x21 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio @@ -9,6 +13,12 @@ binary_sensor: mcp23xxx: mcp23017_hub number: 0 mode: INPUT + - platform: gpio + id: mcp23017_binary_sensor_int + pin: + mcp23xxx: mcp23017_hub_int + number: 0 + mode: INPUT switch: - platform: gpio diff --git a/tests/components/mcp23017/test.esp32-idf.yaml b/tests/components/mcp23017/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/mcp23017/test.esp32-idf.yaml +++ b/tests/components/mcp23017/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/mcp23017/test.esp8266-ard.yaml b/tests/components/mcp23017/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/mcp23017/test.esp8266-ard.yaml +++ b/tests/components/mcp23017/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/mcp23017/test.rp2040-ard.yaml b/tests/components/mcp23017/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/mcp23017/test.rp2040-ard.yaml +++ b/tests/components/mcp23017/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/mcp23s08/common.yaml b/tests/components/mcp23s08/common.yaml index 2170ae0459..327bf02ca9 100644 --- a/tests/components/mcp23s08/common.yaml +++ b/tests/components/mcp23s08/common.yaml @@ -2,3 +2,4 @@ mcp23s08: - id: mcp23s08_hub cs_pin: ${cs_pin} deviceaddress: 0 + interrupt_pin: ${interrupt_pin} diff --git a/tests/components/mcp23s08/test.esp32-idf.yaml b/tests/components/mcp23s08/test.esp32-idf.yaml index a3352cf880..eeb6941645 100644 --- a/tests/components/mcp23s08/test.esp32-idf.yaml +++ b/tests/components/mcp23s08/test.esp32-idf.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO15 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml diff --git a/tests/components/mcp23s08/test.esp8266-ard.yaml b/tests/components/mcp23s08/test.esp8266-ard.yaml index 595f31046a..ffc40b3595 100644 --- a/tests/components/mcp23s08/test.esp8266-ard.yaml +++ b/tests/components/mcp23s08/test.esp8266-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO15 + interrupt_pin: GPIO0 packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml diff --git a/tests/components/mcp23s08/test.rp2040-ard.yaml b/tests/components/mcp23s08/test.rp2040-ard.yaml index 79ea6ce90b..09b87ca3f8 100644 --- a/tests/components/mcp23s08/test.rp2040-ard.yaml +++ b/tests/components/mcp23s08/test.rp2040-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO2 packages: spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml diff --git a/tests/components/mcp23s17/common.yaml b/tests/components/mcp23s17/common.yaml index a89beeb16b..150ecca325 100644 --- a/tests/components/mcp23s17/common.yaml +++ b/tests/components/mcp23s17/common.yaml @@ -2,3 +2,4 @@ mcp23s17: - id: mcp23s17_hub cs_pin: ${cs_pin} deviceaddress: 0 + interrupt_pin: ${interrupt_pin} diff --git a/tests/components/mcp23s17/test.esp32-idf.yaml b/tests/components/mcp23s17/test.esp32-idf.yaml index a3352cf880..eeb6941645 100644 --- a/tests/components/mcp23s17/test.esp32-idf.yaml +++ b/tests/components/mcp23s17/test.esp32-idf.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO15 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml diff --git a/tests/components/mcp23s17/test.esp8266-ard.yaml b/tests/components/mcp23s17/test.esp8266-ard.yaml index 595f31046a..ffc40b3595 100644 --- a/tests/components/mcp23s17/test.esp8266-ard.yaml +++ b/tests/components/mcp23s17/test.esp8266-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO15 + interrupt_pin: GPIO0 packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml diff --git a/tests/components/mcp23s17/test.rp2040-ard.yaml b/tests/components/mcp23s17/test.rp2040-ard.yaml index 79ea6ce90b..09b87ca3f8 100644 --- a/tests/components/mcp23s17/test.rp2040-ard.yaml +++ b/tests/components/mcp23s17/test.rp2040-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO2 packages: spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp new file mode 100644 index 0000000000..5b4f84623e --- /dev/null +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -0,0 +1,303 @@ +#include "../common.h" + +namespace esphome::mitsubishi_cn105::testing { + +struct TestContext { + MockUARTComponent uart; + uart::UARTDevice device{&uart}; + TestableMitsubishiCN105 sut{device}; + + TestContext() { this->sut.set_current_time(0); } +}; + +TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { + auto ctx = TestContext{}; + + ctx.sut.set_current_time(123); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + + ctx.sut.initialize(); + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{123}); +} + +TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Connect response + ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55}); + + ctx.sut.set_current_time(200); + ASSERT_FALSE(ctx.sut.update()); + + // All bytes from UART should be consumed + EXPECT_TRUE(ctx.uart.rx.empty()); + // After successful connect we request status, first settings (0x02) + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{200}); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Clear TX bytes. + ctx.uart.tx.clear(); + + // Settings response + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99}); + + // Settings should still have initial values + EXPECT_FALSE(ctx.sut.status().power_on); + EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan()); + + ctx.sut.set_current_time(300); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.rx.empty()); + + // Check settings that we just read from received package + EXPECT_FALSE(ctx.sut.status().power_on); + EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f); + + // Now fetch room temperature (0x03) + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{300}); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Clear TX bytes. + ctx.uart.tx.clear(); + + // Room temperature response + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00, + 0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5}); + + // Room temperature should still have initial value + EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan()); + + ctx.sut.set_current_time(400); + EXPECT_FALSE(ctx.sut.is_status_initialized()); + ASSERT_TRUE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.rx.empty()); + EXPECT_TRUE(ctx.sut.is_status_initialized()); + + // Check room temperature we just read from received package + EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f); + + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{400}); +} + +TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // No response (no RX data), no retry yet + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + + // Still no response after 1999ms, no retry yet + ctx.sut.set_current_time(1999); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + + // Stop waiting after 2s and retry connect + ctx.sut.set_current_time(2000); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{2000}); +} + +TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // RX noise/unexpected traffic + ctx.uart.push_rx({0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46}); + + // Make sure we have enough bytes in buffer. + ASSERT_GT(ctx.uart.rx.size(), 64); + + // No valid response, no state change expected + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + + // Watchdog interrupts reading (max. 64 bytes at once) so we do not spend the whole loop draining UART + EXPECT_FALSE(ctx.uart.rx.empty()); + + // Next update will read remaining bytes, no state change expected + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_TRUE(ctx.uart.rx.empty()); +} + +TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // Mixed RX stream with partial, malformed, and oversized frames to test parser robustness + ctx.uart.push_rx({// ───────────────────────────── + // Noise (no 0xFC) -> should be ignored via preamble reset + // ──────────────────────────── + 0x01, 0x02, 0x03, 0x04, 0x05, + + // ───────────────────────────── + // Partial frame (declares payload len=5, but we cut it short) + // Later bytes will eventually force checksum mismatch and reset + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x05, 0xAA, 0xBB, + + // ───────────────────────────── + // Invalid header (header byte 3 should be 0x01, header byte 4 should be 0x30) + // Should reset quickly on header mismatch + // ───────────────────────────── + 0xFC, 0x62, 0xFF, 0xFF, 0x02, 0x01, 0x02, 0x00, + + // ───────────────────────────── + // Oversized length field (rejected by payload-too-large check at HEADER_LEN) + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0xFE, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, + 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, + + // ───────────────────────────── + // Valid unknown-type frame (type=0x62), should be parsed successfully then ignored + // Frame: FC 62 01 30 02 AA BB 30 + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x02, 0xAA, 0xBB, 0x30, + + // ───────────────────────────── + // Invalid checksum (should be rejected at checksum check) + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x02, 0x10, 0x20, 0xFF, + + // ───────────────────────────── + // Back-to-back VALID frames (unknown type=0x62) to stress boundary handling. + // Frame A: FC 62 01 30 01 02 6C + // Frame B: FC 62 01 30 01 03 6B + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x01, 0x02, 0x6C, 0xFC, 0x62, 0x01, 0x30, 0x01, 0x03, 0x6B, + + // ───────────────────────────── + // Trailing noise + // ───────────────────────────── + 0x55, 0x66, 0x77, 0x88}); + + // Drain RX - no valid response, no state change expected + int iterations = 0; + while (!ctx.uart.rx.empty() && iterations++ < 10) { + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + } + + EXPECT_TRUE(ctx.uart.rx.empty()); +} + +TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { + auto ctx = TestContext{}; + + ctx.sut.set_update_interval(2000); + ctx.sut.set_current_time(80000); + + // No scheduled status update + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Status update completed, schedule next status update + ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; + ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{80000}); + + // Wait for update_interval (ms) before doing another status update + ASSERT_FALSE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + + ctx.sut.set_current_time(81999); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + + ctx.sut.set_current_time(82000); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_FALSE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); +} + +TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { + auto ctx = TestContext{}; + + ctx.uart.push_rx( + {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56}); + + ctx.sut.update(); + + EXPECT_TRUE(ctx.sut.status().power_on); + EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); +} + +TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { + auto ctx = TestContext{}; + + ctx.uart.push_rx( + {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xB7}); + + ctx.sut.update(); + + EXPECT_FALSE(ctx.sut.status().power_on); + EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); +} + +TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { + auto ctx = TestContext{}; + + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().room_temperature, 16.0f); +} + +TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { + auto ctx = TestContext{}; + + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.cpp b/tests/components/mitsubishi_cn105/common.cpp new file mode 100644 index 0000000000..50993c5c2c --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.cpp @@ -0,0 +1,7 @@ +#include "common.h" + +namespace esphome::mitsubishi_cn105 { + +uint32_t get_loop_time_ms() { return testing::TestableMitsubishiCN105::test_loop_time_ms; } + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h new file mode 100644 index 0000000000..ed55c3dc0c --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(std::initializer_list data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +}; + +class TestableMitsubishiCN105 : public MitsubishiCN105 { + public: + using MitsubishiCN105::MitsubishiCN105; + using MitsubishiCN105::State; + using MitsubishiCN105::state_; + using MitsubishiCN105::write_timeout_start_ms_; + using MitsubishiCN105::status_update_start_ms_; + + void set_state(State s) { this->set_state_(s); } + + static inline uint32_t test_loop_time_ms = 0; + + void set_current_time(uint32_t ms) { test_loop_time_ms = ms; } +}; + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/pca9554/common.yaml b/tests/components/pca9554/common.yaml index 9e5e7f3342..82a88b90aa 100644 --- a/tests/components/pca9554/common.yaml +++ b/tests/components/pca9554/common.yaml @@ -3,6 +3,11 @@ pca9554: i2c_id: i2c_bus pin_count: 8 address: 0x3F + - id: pca9554_hub_int + i2c_id: i2c_bus + pin_count: 8 + address: 0x3E + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/pca9554/test.esp32-idf.yaml b/tests/components/pca9554/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/pca9554/test.esp32-idf.yaml +++ b/tests/components/pca9554/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pca9554/test.esp8266-ard.yaml b/tests/components/pca9554/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/pca9554/test.esp8266-ard.yaml +++ b/tests/components/pca9554/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/pca9554/test.rp2040-ard.yaml b/tests/components/pca9554/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/pca9554/test.rp2040-ard.yaml +++ b/tests/components/pca9554/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/pcf8574/common.yaml b/tests/components/pcf8574/common.yaml index 09fa33164e..8a26b93015 100644 --- a/tests/components/pcf8574/common.yaml +++ b/tests/components/pcf8574/common.yaml @@ -3,6 +3,11 @@ pcf8574: i2c_id: i2c_bus address: 0x21 pcf8575: false + - id: pcf8574_hub_int + i2c_id: i2c_bus + address: 0x22 + pcf8575: false + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/pcf8574/test.esp32-idf.yaml b/tests/components/pcf8574/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/pcf8574/test.esp32-idf.yaml +++ b/tests/components/pcf8574/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pcf8574/test.esp8266-ard.yaml b/tests/components/pcf8574/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/pcf8574/test.esp8266-ard.yaml +++ b/tests/components/pcf8574/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/pcf8574/test.rp2040-ard.yaml b/tests/components/pcf8574/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/pcf8574/test.rp2040-ard.yaml +++ b/tests/components/pcf8574/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 2344622081..77a77fa3e4 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -1,7 +1,11 @@ pi4ioe5v6408: - i2c_id: i2c_bus - id: pi4ioe1 - address: 0x44 + - i2c_id: i2c_bus + id: pi4ioe1 + address: 0x44 + - i2c_id: i2c_bus + id: pi4ioe1_int + address: 0x45 + interrupt_pin: ${interrupt_pin} switch: - platform: gpio @@ -16,3 +20,8 @@ binary_sensor: pin: pi4ioe5v6408: pi4ioe1 number: 1 + - platform: gpio + id: sensor1_int + pin: + pi4ioe5v6408: pi4ioe1_int + number: 1 diff --git a/tests/components/pi4ioe5v6408/test.esp32-idf.yaml b/tests/components/pi4ioe5v6408/test.esp32-idf.yaml index 9a4779d822..a6eb3c1cb1 100644 --- a/tests/components/pi4ioe5v6408/test.esp32-idf.yaml +++ b/tests/components/pi4ioe5v6408/test.esp32-idf.yaml @@ -1,6 +1,7 @@ substitutions: i2c_sda: GPIO21 i2c_scl: GPIO22 + interrupt_pin: GPIO15 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml b/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml index 3429a2952a..cd6fef3042 100644 --- a/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml +++ b/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml @@ -1,6 +1,7 @@ substitutions: i2c_sda: GPIO4 i2c_scl: GPIO5 + interrupt_pin: GPIO2 packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/time/common.yaml b/tests/components/time/common.yaml index 465be045db..cd258c7aa6 100644 --- a/tests/components/time/common.yaml +++ b/tests/components/time/common.yaml @@ -6,5 +6,9 @@ api: time: - platform: homeassistant + on_time: + - seconds: "0,10,20,30,40,50" + then: + - logger.log: "CronTrigger fired (every 10 seconds)" - platform: sntp id: sntp_time diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b652b4174c..7c85bf753c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -198,6 +198,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s ' - "-g" # Add debug symbols', ) + # Replace external component path placeholder if present + if "EXTERNAL_COMPONENT_PATH" in content: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) + return content diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index 3ff7ab01bd..da36da4de1 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -22,6 +22,8 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: responses: @@ -46,7 +48,7 @@ modbus: modbus_controller: - address: 1 id: modbus_controller_ok - max_cmd_retries: 0 + max_cmd_retries: 2 update_interval: 1s - address: 2 id: modbus_controller_slow @@ -89,4 +91,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml index e3e8c8c8da..9bc4dc50e9 100644 --- a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -22,6 +22,8 @@ uart: uart_mock: - id: virtual_uart_dev baud_rate: 9600 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: on_tx: @@ -40,7 +42,8 @@ uart_mock: 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; - uart_mock.inject_rx: # Second USB packet: rest of response (staged with 40ms latency) delay: 40ms - data: !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + data: + !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x42,0x6F,0xCC,0xCD,0x43,0x7C,0xB8,0x10,0x3D,0x38,0x51,0xEC, 0x43,0x81,0x1B,0xE7,0x3B,0x03,0x12,0x6F,0x50,0x1B}; @@ -61,4 +64,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml new file mode 100644 index 0000000000..b657a6fd21 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server.yaml @@ -0,0 +1,124 @@ +esphome: + name: uart-mock-modbus-server-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) + - delay: 100ms + # Read holding register 7 on device 2 + # Reply from device 2 + # Read holding register 5 on device 1 (read_after_peer_response) + inject_rx: + [ + 0x02, + 0x03, + 0x00, + 0x07, + 0x00, + 0x01, + 0x35, + 0xF8, + 0x02, + 0x03, + 0x02, + 0x00, + 0xF0, + 0xFC, + 0x00, + 0x01, + 0x03, + 0x00, + 0x05, + 0x00, + 0x01, + 0x94, + 0x0B, + ] + - delay: 100ms + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response + - delay: 100ms + # Read holding register 7 on device 2, with no response + # Read holding register A on device 1 (read_after_peer_timeout) + inject_rx: + [ + 0x02, + 0x03, + 0x00, + 0x07, + 0x00, + 0x01, + 0x35, + 0xF8, + 0x01, + 0x03, + 0x00, + 0x0A, + 0x00, + 0x01, + 0xA4, + 0x08, + ] + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_controller: + - address: 1 + server_registers: + - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(basic_read).publish_state(1); + return 1; + - address: 0x05 + value_type: U_WORD + read_lambda: |- + id(read_after_peer_response).publish_state(1); + return 1; + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(read_after_peer_timeout).publish_state(1); + return 1; + +sensor: + - platform: template + name: "basic_read" + id: basic_read + - platform: template + name: "read_after_peer_response" + id: read_after_peer_response + - platform: template + name: "read_after_peer_timeout" + id: read_after_peer_timeout + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml new file mode 100644 index 0000000000..f0f2c56a36 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -0,0 +1,180 @@ +esphome: + name: uart-mock-modbus-server-contro + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 1s + id: modbus_controller_1 + + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 99; + - address: 0x03 + value_type: S_WORD + read_lambda: return -99; + - address: 0x05 + value_type: U_DWORD + read_lambda: return 16909060; + - address: 0x08 + value_type: S_DWORD + read_lambda: return -16909060; + - address: 0x0B + value_type: U_DWORD_R + read_lambda: return 67305985; + - address: 0x0E + value_type: S_DWORD_R + read_lambda: return -67305985; + - address: 0x11 + value_type: U_QWORD + read_lambda: return 72623859790382856; + - address: 0x16 + value_type: S_QWORD + read_lambda: return -72623859790382856; + - address: 0x1B + value_type: U_QWORD_R + read_lambda: return 578437695752307201; + - address: 0x20 + value_type: S_QWORD_R + read_lambda: return -578437695752307201; + - address: 0x25 + value_type: FP32 + read_lambda: return 3.14; + - address: 0x28 + value_type: FP32_R + read_lambda: return 3.14; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml new file mode 100644 index 0000000000..7ec67b03db --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -0,0 +1,118 @@ +esphome: + name: uart-mock-modbus-server-mult + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + update_interval: 1s + id: modbus_controller_1 + - address: 2 + modbus_id: virtual_modbus_client + update_interval: 1s + id: modbus_controller_2 + + - address: 1 + modbus_id: virtual_modbus_server + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 2 + modbus_id: virtual_modbus_server_2 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 929; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_2 + name: "reg_u_word_2" + address: 0x01 + register_type: holding + value_type: U_WORD + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_server_2).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml new file mode 100644 index 0000000000..3edcc73f07 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -0,0 +1,330 @@ +esphome: + name: uart-mock-modbus-srv-write + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_u_word + type: uint16_t + initial_value: "11" + - id: stored_s_word + type: int16_t + initial_value: "-11" + - id: stored_u_dword + type: uint32_t + initial_value: "1001" + - id: stored_s_dword + type: int32_t + initial_value: "-1001" + - id: stored_u_dword_r + type: uint32_t + initial_value: "3003" + - id: stored_s_dword_r + type: int32_t + initial_value: "-3003" + - id: stored_u_qword + type: uint64_t + initial_value: "5005" + - id: stored_s_qword + type: int64_t + initial_value: "-5005" + - id: stored_u_qword_r + type: uint64_t + initial_value: "7007" + - id: stored_s_qword_r + type: int64_t + initial_value: "-7007" + - id: stored_fp32 + type: float + initial_value: "1.5" + - id: stored_fp32_r + type: float + initial_value: "2.5" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 2s + id: modbus_controller_1 + + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return id(stored_u_word); + write_lambda: id(stored_u_word) = x; return true; + - address: 0x03 + value_type: S_WORD + read_lambda: return id(stored_s_word); + write_lambda: id(stored_s_word) = x; return true; + - address: 0x05 + value_type: U_DWORD + read_lambda: return id(stored_u_dword); + write_lambda: id(stored_u_dword) = x; return true; + - address: 0x08 + value_type: S_DWORD + read_lambda: return id(stored_s_dword); + write_lambda: id(stored_s_dword) = x; return true; + - address: 0x0B + value_type: U_DWORD_R + read_lambda: return id(stored_u_dword_r); + write_lambda: id(stored_u_dword_r) = x; return true; + - address: 0x0E + value_type: S_DWORD_R + read_lambda: return id(stored_s_dword_r); + write_lambda: id(stored_s_dword_r) = x; return true; + - address: 0x11 + value_type: U_QWORD + read_lambda: return id(stored_u_qword); + write_lambda: id(stored_u_qword) = x; return true; + - address: 0x16 + value_type: S_QWORD + read_lambda: return id(stored_s_qword); + write_lambda: id(stored_s_qword) = x; return true; + - address: 0x1B + value_type: U_QWORD_R + read_lambda: return id(stored_u_qword_r); + write_lambda: id(stored_u_qword_r) = x; return true; + - address: 0x20 + value_type: S_QWORD_R + read_lambda: return id(stored_s_qword_r); + write_lambda: id(stored_s_qword_r) = x; return true; + - address: 0x25 + value_type: FP32 + read_lambda: return id(stored_fp32); + write_lambda: id(stored_fp32) = x; return true; + - address: 0x28 + value_type: FP32_R + read_lambda: return id(stored_fp32_r); + write_lambda: id(stored_fp32_r) = x; return true; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + min_value: -16777215 + max_value: 16777215 + step: 0.01 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + min_value: -16777215 + max_value: 16777215 + step: 0.01 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index f4cf0bde37..c670864085 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -22,6 +22,8 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: on_tx: @@ -61,4 +63,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 5792a8e804..d42b50ecdb 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -346,3 +346,109 @@ class SensorStateCollector: else: self._waiters.append((condition, future)) return future + + +class SensorTracker: + """Data-driven sensor state tracker with expected-value futures. + + Tracks sensor state updates and resolves futures when sensors report + specific expected values. Eliminates per-sensor future boilerplate. + + Usage:: + + tracker = SensorTracker(["reg_u_word", "reg_s_word"]) + futures = tracker.expect_all({"reg_u_word": 99, "reg_s_word": -99}) + # ... subscribe_states with tracker.on_state, start scenario ... + await tracker.await_all(futures) + """ + + def __init__(self, sensor_names: list[str]) -> None: + self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names} + self.key_to_sensor: dict[int, str] = {} + self._expectations: dict[str, list[tuple[object, asyncio.Future]]] = {} + + _ANY = object() # Sentinel: match any value + + def expect(self, name: str, value: object) -> asyncio.Future: + """Register an expected value for *name* and return a future for it.""" + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._expectations.setdefault(name, []).append((value, future)) + return future + + def expect_any(self, name: str) -> asyncio.Future: + """Register a future that resolves on *any* state update for *name*.""" + return self.expect(name, self._ANY) + + def expect_all(self, expected: dict[str, object]) -> dict[str, asyncio.Future]: + """Call ``expect`` for every entry and return a dict of futures.""" + return {name: self.expect(name, value) for name, value in expected.items()} + + def on_state(self, state: EntityState) -> None: + """State callback suitable for ``subscribe_states``.""" + if not isinstance(state, SensorState) or state.missing_state: + return + sensor_name = self.key_to_sensor.get(state.key) + if not sensor_name or sensor_name not in self.sensor_states: + return + self.sensor_states[sensor_name].append(state.state) + for expected_value, future in self._expectations.get(sensor_name, []): + if not future.done() and ( + expected_value is self._ANY or state.state == expected_value + ): + future.set_result(True) + break + + async def await_change( + self, future: asyncio.Future, name: str, timeout: float = 2.0 + ) -> None: + """Wait for a sensor future to resolve; fail the test on timeout.""" + try: + await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + import pytest + + pytest.fail( + f"Timeout waiting for {name} change. Received sensor states:\n" + f" {name}: {self.sensor_states[name]}\n" + ) + + async def await_must_not_change( + self, future: asyncio.Future, name: str, timeout: float = 2.0 + ) -> None: + """Assert a sensor future does NOT resolve within the timeout.""" + try: + await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + return # Expected + import pytest + + pytest.fail( + f"{name} change should not have been triggered, but was. " + f"Received sensor states:\n {name}: {self.sensor_states[name]}\n" + ) + + async def await_all( + self, futures: dict[str, asyncio.Future], timeout: float = 2.0 + ) -> None: + """Await every future in *futures*, failing with per-sensor diagnostics.""" + for name, future in futures.items(): + await self.await_change(future, name, timeout=timeout) + + async def setup_and_start_scenario(self, client) -> list: + """Wire up subscriptions, wait for initial states, press Start Scenario.""" + entities, _ = await client.list_entities_services() + self.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(self.sensor_states.keys())) + ) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(self.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + import pytest + + pytest.fail("Timeout waiting for initial states") + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + return entities diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index e341d86f53..e8dfa1b822 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -14,15 +14,67 @@ test_uart_mock_modbus_no_threshold : from __future__ import annotations import asyncio -from pathlib import Path +from collections.abc import Callable +from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, EntityState, SensorState +from aioesphomeapi import NumberInfo import pytest -from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity +from .state_utils import SensorTracker, find_entity from .types import APIClientConnectedFactory, RunCompiledFunction +@dataclass +class RegisterTestCase: + """Test parameters for a single modbus register write/read round-trip.""" + + initial_value: object + write_number_name: str + write_value: float + post_write_value: object + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_modbus_line_callback() -> tuple[Callable[[str], None], list[str], list[str]]: + """Return a (callback, error_lines, warning_lines) tuple for tracking modbus log output. + + Only captures bus-level modbus messages ([modbus:]), not modbus_controller + scheduling noise (e.g. "Duplicate modbus command found"). + """ + error_log_lines: list[str] = [] + warning_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "[E][modbus:" in line: + error_log_lines.append(line) + if "[W][modbus:" in line: + warning_log_lines.append(line) + + return line_callback, error_log_lines, warning_log_lines + + +def _assert_no_modbus_errors( + error_log_lines: list[str], warning_log_lines: list[str] +) -> None: + assert len(error_log_lines) == 0, ( + "Expect no errors logged by the modbus mock, but got:\n" + + "\n".join(error_log_lines) + ) + assert len(warning_log_lines) == 0, ( + "Expect no warnings logged by the modbus mock, but got:\n" + + "\n".join(warning_log_lines) + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_uart_mock_modbus( yaml_config: str, @@ -30,127 +82,41 @@ async def test_uart_mock_modbus( api_client_connected: APIClientConnectedFactory, ) -> None: """Test basic modbus data parsing.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" + + tracker = SensorTracker( + [ + "basic_register", + "delayed_response", + "late_response", + "no_response", + "exception_response", + ] ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "basic_register": [], - "delayed_response": [], - "late_response": [], - "no_response": [], - "exception_response": [], - } - - basic_register_changed = loop.create_future() - delayed_response_changed = loop.create_future() - late_response_changed = loop.create_future() - no_response_changed = loop.create_future() - exception_response_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - if ( - sensor_name == "basic_register" - and state.state == 259.0 - and not basic_register_changed.done() - ): - basic_register_changed.set_result(True) - elif ( - sensor_name == "delayed_response" - and state.state == 255.0 - and not delayed_response_changed.done() - ): - delayed_response_changed.set_result(True) - elif ( - sensor_name == "late_response" and not late_response_changed.done() - ): - late_response_changed.set_result(True) - elif sensor_name == "no_response" and not no_response_changed.done(): - no_response_changed.set_result(True) - elif ( - sensor_name == "exception_response" - and not exception_response_changed.done() - ): - exception_response_changed.set_result(True) + basic_register_changed = tracker.expect("basic_register", 259.0) + delayed_response_changed = tracker.expect("delayed_response", 255.0) + # late_response / no_response / exception_response: expect *any* value + # (these should never fire, so we use a permissive match via expect_any) + late_response_changed = tracker.expect_any("late_response") + no_response_changed = tracker.expect_any("no_response") + exception_response_changed = tracker.expect_any("exception_response") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() + await tracker.setup_and_start_scenario(client) - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") - - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) - - try: - await asyncio.wait_for(delayed_response_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for delayed_response change. Received sensor states:\n" - f" delayed_response: {sensor_states['delayed_response']}\n" - ) - - try: - await asyncio.wait_for(late_response_changed, timeout=2.0) - pytest.fail( - f"late_response change should not have been triggered, but was. Received sensor states:\n" - f" late_response: {sensor_states['late_response']}\n" - ) - except TimeoutError: - pass # Expected timeout since we never inject a response for late_response - - try: - await asyncio.wait_for(no_response_changed, timeout=2.0) - pytest.fail( - f"no_response change should not have been triggered, but was. Received sensor states:\n" - f" no_response: {sensor_states['no_response']}\n" - ) - except TimeoutError: - pass # Expected timeout since we never inject a response for no_response - - # Wait for basic register to be updated with successful parse - try: - await asyncio.wait_for(basic_register_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for Basic Register change. Received sensor states:\n" - f" basic_register: {sensor_states['basic_register']}\n" - ) - - try: - await asyncio.wait_for(exception_response_changed, timeout=2.0) - pytest.fail( - f"exception_response change should not have been triggered, but was. Received sensor states:\n" - f" exception_response: {sensor_states['exception_response']}\n" - ) - except TimeoutError: - pass + await tracker.await_change(delayed_response_changed, "delayed_response") + await tracker.await_change(basic_register_changed, "basic_register") + # Run all "must not change" checks concurrently — each waits the full + # timeout, so sequential execution would multiply the wall time. + await asyncio.gather( + tracker.await_must_not_change(late_response_changed, "late_response"), + tracker.await_must_not_change(no_response_changed, "no_response"), + tracker.await_must_not_change( + exception_response_changed, "exception_response" + ), + ) @pytest.mark.asyncio @@ -159,69 +125,17 @@ async def test_uart_mock_modbus_timing( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test basic modbus data parsing.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) + """Test modbus timing with multi-register SDM meter response.""" - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "sdm_voltage": [], - } - - voltage_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is a good voltage reading (243V) - if ( - sensor_name == "sdm_voltage" - and state.state > 200.0 - and not voltage_changed.done() - ): - voltage_changed.set_result(True) + tracker = SensorTracker(["sdm_voltage"]) + voltage_changed = tracker.expect_any("sdm_voltage") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() - - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") - - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) - - # Wait for voltage to be updated with successful parse - try: - await asyncio.wait_for(voltage_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for SDM voltage change. Received sensor states:\n" - f" sdm_voltage: {sensor_states['sdm_voltage']}\n" - ) + await tracker.setup_and_start_scenario(client) + await tracker.await_change(voltage_changed, "sdm_voltage") @pytest.mark.asyncio @@ -234,66 +148,187 @@ async def test_uart_mock_modbus_no_threshold( Without the 50ms fallback timeout, the chunked response with a 40ms gap between USB packets would cause a false timeout and CRC failure cascade. + Bus-level warnings (CRC failures, buffer clears) are expected during + chunked reassembly — the test only verifies the final value arrives. """ - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "sdm_voltage": [], - } - - voltage_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is a good voltage reading (243V) - if ( - sensor_name == "sdm_voltage" - and state.state > 200.0 - and not voltage_changed.done() - ): - voltage_changed.set_result(True) + tracker = SensorTracker(["sdm_voltage"]) + voltage_changed = tracker.expect_any("sdm_voltage") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() + await tracker.setup_and_start_scenario(client) + await tracker.await_change(voltage_changed, "sdm_voltage") - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) +@pytest.mark.asyncio +@pytest.mark.xfail( + reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", + strict=True, +) +async def test_uart_mock_modbus_server( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server parsing with peer traffic on a shared bus.""" - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) + tracker = SensorTracker( + ["basic_read", "read_after_peer_response", "read_after_peer_timeout"] + ) + futures = tracker.expect_all( + { + "basic_read": 1, + "read_after_peer_response": 1, + "read_after_peer_timeout": 1, + } + ) - # Wait for voltage to be updated with successful parse - try: - await asyncio.wait_for(voltage_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for SDM voltage change. Received sensor states:\n" - f" sdm_voltage: {sensor_states['sdm_voltage']}\n" + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller functionality for all read register types.""" + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = { + "reg_u_word": 99, + "reg_s_word": -99, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(3.14), + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller write functionality for all register value types. + + Verifies that writing to modbus server registers via the controller updates + the server's stored values, which are then read back correctly on the next poll. + All 12 value types are tested: U/S_WORD, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R). + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + register_test_cases: dict[str, RegisterTestCase] = { + "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), + "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), + "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), + "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), + "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), + "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), + "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), + "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), + "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), + "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), + "reg_fp32": RegisterTestCase( + pytest.approx(1.5, abs=0.01), + "write_fp32", + 3.14, + pytest.approx(3.14, abs=0.01), + ), + "reg_fp32_r": RegisterTestCase( + pytest.approx(2.5, abs=0.01), + "write_fp32_r", + 6.28, + pytest.approx(6.28, abs=0.01), + ), + } + + tracker = SensorTracker(list(register_test_cases.keys())) + + # Phase 1: expect initial baseline values + initial_futures = tracker.expect_all( + {name: case.initial_value for name, case in register_test_cases.items()} + ) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + {name: case.post_write_value for name, case in register_test_cases.items()} + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Wait for initial baseline values to confirm the controller <-> server + # connection is working before issuing writes + await tracker.await_all(initial_futures, timeout=4.0) + + # Issue write commands for all register types + for case in register_test_cases.values(): + entity = find_entity(entities, case.write_number_name, NumberInfo) + assert entity is not None, ( + f"{case.write_number_name} number entity not found" ) + client.number_command(entity.key, case.write_value) + + # Wait for sensors to reflect the written values (round-trip write+read) + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +@pytest.mark.xfail( + reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", + strict=True, +) +async def test_uart_mock_modbus_server_controller_multiple( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller functionality with multiple servers.""" + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = {"reg_u_word": 919, "reg_u_word_2": 929} + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 28f111d758..948aabaa66 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1037,7 +1037,7 @@ def test_get_all_dependencies_platform_component() -> None: with ( patch("esphome.loader.get_component") as mock_get_component, - patch("helpers.get_platform") as mock_get_platform, + patch("esphome.loader.get_platform") as mock_get_platform, ): mock_get_platform.return_value = platform_comp mock_get_component.return_value = None @@ -1061,7 +1061,7 @@ def test_get_all_dependencies_platform_component_with_dependencies() -> None: with ( patch("esphome.loader.get_component") as mock_get_component, - patch("helpers.get_platform") as mock_get_platform, + patch("esphome.loader.get_platform") as mock_get_platform, ): mock_get_platform.return_value = platform_comp mock_get_component.side_effect = lambda name: ( diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py new file mode 100644 index 0000000000..48988fb03f --- /dev/null +++ b/tests/unit_tests/components/test_time.py @@ -0,0 +1,80 @@ +"""Tests for time component cron expression parsing.""" + +from esphome.components.time import _parse_cron_part + + +def test_star_slash_seconds() -> None: + assert _parse_cron_part("*/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} + + +def test_star_slash_minutes() -> None: + assert _parse_cron_part("*/5", 0, 59, {}) == { + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + } + + +def test_star_slash_hours() -> None: + assert _parse_cron_part("*/2", 0, 23, {}) == { + 0, + 2, + 4, + 6, + 8, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + } + + +def test_star_slash_days_of_month() -> None: + """days_of_month starts at 1, not 0.""" + assert _parse_cron_part("*/5", 1, 31, {}) == {1, 6, 11, 16, 21, 26, 31} + + +def test_question_slash() -> None: + assert _parse_cron_part("?/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} + + +def test_empty_offset_slash() -> None: + """Empty offset defaults to min_value.""" + assert _parse_cron_part("/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} + + +def test_empty_offset_slash_nonzero_min() -> None: + """Empty offset defaults to min_value, not 0.""" + assert _parse_cron_part("/5", 1, 31, {}) == {1, 6, 11, 16, 21, 26, 31} + + +def test_numeric_offset_slash() -> None: + assert _parse_cron_part("5/10", 0, 60, {}) == {5, 15, 25, 35, 45, 55} + + +def test_star() -> None: + assert _parse_cron_part("*", 0, 59, {}) == set(range(0, 60)) + + +def test_question() -> None: + assert _parse_cron_part("?", 0, 59, {}) == set(range(0, 60)) + + +def test_range() -> None: + assert _parse_cron_part("1-5", 0, 59, {}) == {1, 2, 3, 4, 5} + + +def test_single_value() -> None: + assert _parse_cron_part("30", 0, 59, {}) == {30}