From ea0ce710a8cf5d93a364826f6581f10e7e8c5d5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Apr 2026 13:55:06 -1000 Subject: [PATCH 1/7] [api] Split Noise handshake state_action_ to reduce stack pressure (#15464) --- .../components/api/api_frame_helper_noise.cpp | 250 +++++++++--------- .../components/api/api_frame_helper_noise.h | 5 + 2 files changed, 136 insertions(+), 119 deletions(-) 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_(); From 07f6be679fcfe4edf700b496de86803cce219227 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 5 Apr 2026 21:20:48 -0500 Subject: [PATCH 2/7] [esp32] Add signed app verification without hardware secure boot (#15357) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/esp32/__init__.py | 104 ++++++++++++++++++ esphome/components/esp32/post_build.py.script | 96 +++++++++++++++- esphome/components/ota/ota_backend.h | 1 + .../components/ota/ota_backend_esp_idf.cpp | 8 ++ esphome/core/defines.h | 1 + esphome/espota2.py | 8 ++ tests/components/esp32/dummy_signing_key.pem | 41 +++++++ .../esp32/test-signed_ota.esp32-s3-idf.yaml | 10 ++ 8 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32/dummy_signing_key.pem create mode 100644 tests/components/esp32/test-signed_ota.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5cae67db13..0d8a221524 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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( 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/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/core/defines.h b/esphome/core/defines.h index faa8c6d4b0..141f6d2be4 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 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/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 From aac74f4c947d55726129f1914e78a85f84bee375 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:48:00 -0400 Subject: [PATCH 3/7] [ags10] Fix wrong type passed to cg.templatable for set_zero_point mode (#15467) --- esphome/components/ags10/sensor.py | 10 +++++++--- tests/components/ags10/common.yaml | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) 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/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 From 10f08e080299296db5afb8ca8a25f9d423401bca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Apr 2026 17:30:56 -1000 Subject: [PATCH 4/7] [esp8266] Add crash handler for post-mortem diagnostics (#15465) --- esphome/components/api/api_connection.h | 6 + esphome/components/esp8266/__init__.py | 1 + esphome/components/esp8266/crash_handler.cpp | 235 +++++++++++++++++++ esphome/components/esp8266/crash_handler.h | 20 ++ esphome/components/esp8266/preferences.cpp | 11 +- esphome/components/logger/logger_esp8266.cpp | 7 + esphome/core/defines.h | 1 + 7 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 esphome/components/esp8266/crash_handler.cpp create mode 100644 esphome/components/esp8266/crash_handler.h 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/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/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/core/defines.h b/esphome/core/defines.h index 141f6d2be4..d92fb6e98a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -331,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 From 1de94c1a8489ca1ed5cf229e6f0e41bbbb6e065d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Apr 2026 18:02:06 -1000 Subject: [PATCH 5/7] [api] Add max_value proto option for constant-size varint codegen (#15424) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 6 +++ esphome/components/api/api_pb2.cpp | 6 +-- script/api_protobuf/api_protobuf.py | 68 ++++++++++++++++++++---- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 96ee2fb920..1e03675999 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1606,7 +1606,7 @@ message BluetoothLEAdvertisementResponse { message BluetoothLERawAdvertisement { uint64 address = 1 [(force) = true]; sint32 rssi = 2 [(force) = true]; - uint32 address_type = 3; + uint32 address_type = 3 [(max_value) = 4]; bytes data = 4 [(fixed_array_size) = 62, (force) = true]; } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 02600f0977..0aa9e814cf 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -96,4 +96,10 @@ extend google.protobuf.FieldOptions { // variant of the calc_ method. Use on fields that are almost always non-default // to eliminate dead branches on hot paths. optional bool force = 50016 [default=false]; + + // max_value: Maximum value a field can have. + // When max_value < 128, the code generator emits constant-size calculations + // and direct byte writes instead of varint branching, since the encoded varint + // is guaranteed to be 1 byte. + optional uint32 max_value = 50017; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ae2cd2bae8..f25d269e8f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2255,15 +2255,15 @@ void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer) const { buffer.encode_varint_raw(encode_zigzag32(this->rssi)); buffer.encode_uint32(3, this->address_type); buffer.write_raw_byte(34); - buffer.encode_varint_raw(this->data_len); + buffer.write_raw_byte(static_cast(this->data_len)); buffer.encode_raw(this->data, this->data_len); } uint32_t BluetoothLERawAdvertisement::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint64_force(1, this->address); size += ProtoSize::calc_sint32_force(1, this->rssi); - size += ProtoSize::calc_uint32(1, this->address_type); - size += ProtoSize::calc_length_force(1, this->data_len); + size += this->address_type ? 2 : 0; + size += 2 + this->data_len; return size; } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer) const { diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f2a11141af..c17f16412c 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -156,6 +156,11 @@ class TypeInfo(ABC): """Check if this field should always be encoded (skip zero/empty check).""" return get_field_opt(self._field, pb.force, False) + @property + def max_value(self) -> int | None: + """Get the max_value option for this field, or None if not set.""" + return get_field_opt(self._field, pb.max_value, None) + @property def wire_type(self) -> WireType: """Get the wire type for the field.""" @@ -235,37 +240,56 @@ class TypeInfo(ABC): "encode_bool": "buffer.write_raw_byte({value} ? 0x01 : 0x00);", } + # When max_value < 128, the varint is always 1 byte — use a direct byte write + RAW_ENCODE_SMALL_MAP: dict[str, str] = { + "encode_uint32": "buffer.write_raw_byte(static_cast({value}));", + "encode_uint64": "buffer.write_raw_byte(static_cast({value}));", + } + def _encode_with_precomputed_tag(self, value_expr: str) -> str | None: """Try to emit a precomputed-tag encode for a forced field. Returns the raw encode string if the tag is a single byte and the encode_func has a known raw equivalent, or None otherwise. + When max_value < 128, uses direct byte write instead of varint encoding. """ if not self.force: return None tag = self.calculate_tag() if tag >= 128: return None - raw_expr = self.RAW_ENCODE_MAP.get(self.encode_func) + max_val = self.max_value + raw_expr = None + if max_val is not None and max_val < 128: + raw_expr = self.RAW_ENCODE_SMALL_MAP.get(self.encode_func) + if raw_expr is None: + raw_expr = self.RAW_ENCODE_MAP.get(self.encode_func) if raw_expr is None: return None return f"buffer.write_raw_byte({tag});\n{raw_expr.format(value=value_expr)}" def _encode_bytes_with_precomputed_tag( - self, data_expr: str, len_expr: str + self, data_expr: str, len_expr: str, max_len: int | None = None ) -> str | None: """Try to emit a precomputed-tag encode for a forced bytes/string field. Returns the raw encode string if the tag is a single byte, or None. + When max_len < 128, uses direct byte write for the length varint. """ if not self.force: return None tag = self.calculate_tag() if tag >= 128: return None + # When max_len < 128, length varint is always 1 byte + len_encode = ( + f"buffer.write_raw_byte(static_cast({len_expr}));" + if max_len is not None and max_len < 128 + else f"buffer.encode_varint_raw({len_expr});" + ) return ( f"buffer.write_raw_byte({tag});\n" - f"buffer.encode_varint_raw({len_expr});\n" + f"{len_encode}\n" f"buffer.encode_raw({data_expr}, {len_expr});" ) @@ -346,6 +370,25 @@ class TypeInfo(ABC): value = value_expr or name return f"size += ProtoSize::{method}({field_id_size}, {value});" + def _get_single_byte_varint_size( + self, name: str, force: bool, extra_expr: str | None = None + ) -> str: + """Size calculation when the varint is guaranteed to be 1 byte. + + Used when max_value < 128 or fixed_array_size < 128. + The fixed part is field_id_size + 1 (tag + 1-byte varint). + + Args: + name: Expression to check for zero (non-force only) + force: Whether to skip the zero check + extra_expr: Additional variable expression to add (e.g., data length) + """ + fixed = self.calculate_field_id_size() + 1 + size_expr = f"{fixed} + {extra_expr}" if extra_expr else str(fixed) + if force: + return f"size += {size_expr};" + return f"size += {name} ? {size_expr} : 0;" + @abstractmethod def get_size_calculation(self, name: str, force: bool = False) -> str: """Calculate the size needed for encoding this field. @@ -1191,8 +1234,9 @@ class FixedArrayBytesType(TypeInfo): @property def encode_content(self) -> str: + max_len = self.array_size if isinstance(self.array_size, int) else None if result := self._encode_bytes_with_precomputed_tag( - f"this->{self.field_name}", f"this->{self.field_name}_len" + f"this->{self.field_name}", f"this->{self.field_name}_len", max_len=max_len ): return result if self.force: @@ -1212,13 +1256,14 @@ class FixedArrayBytesType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # Use the actual length stored in the _len field length_field = f"this->{self.field_name}_len" - field_id_size = self.calculate_field_id_size() - if force: - # For repeated fields, always calculate size (no zero check) - return f"size += ProtoSize::calc_length_force({field_id_size}, {length_field});" - # For non-repeated fields, length already checks for zero - return f"size += ProtoSize::calc_length({field_id_size}, {length_field});" + # When array_size < 128, length varint is always 1 byte + if isinstance(self.array_size, int) and self.array_size < 128: + return self._get_single_byte_varint_size( + length_field, force, extra_expr=length_field + ) + + return self._get_simple_size_calculation(length_field, force, "length") def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -1245,6 +1290,9 @@ class UInt32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: + max_val = self.max_value + if max_val is not None and max_val < 128: + return self._get_single_byte_varint_size(name, force) return self._get_simple_size_calculation(name, force, "uint32") def get_estimated_size(self) -> int: From 7644f17cf61f08a36304be3309dd26e229c1312d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:05:04 -0400 Subject: [PATCH 6/7] [at581x] Fix codegen crash when using lambdas for frequency/time/power (#15468) --- esphome/components/at581x/__init__.py | 32 ++++++++++----------------- tests/components/at581x/common.yaml | 5 +++++ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 34e6570628..4923491f0c 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -173,8 +173,10 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_hw_frontend_reset(template_)) if freq := config.get(CONF_FREQUENCY): - template_ = await cg.templatable(freq, args, float) - template_ = int(template_ / 1000000) + if cg.is_template(freq): + template_ = await cg.templatable(freq, args, cg.int32) + else: + template_ = int(freq / 1000000) cg.add(var.set_frequency(template_)) if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: @@ -182,31 +184,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(selfcheck, args, cg.int32) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(protect, args, cg.int32) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(trig_base, args, cg.int32) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(trig_keep, args, cg.int32) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: @@ -214,8 +204,10 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_stage_gain(template_)) if power := config.get(CONF_POWER_CONSUMPTION): - template_ = await cg.templatable(power, args, float) - template_ = int(template_ * 1000000) + if cg.is_template(power): + template_ = await cg.templatable(power, args, cg.int32) + else: + template_ = int(power * 1000000) cg.add(var.set_power_consumption(template_)) return var diff --git a/tests/components/at581x/common.yaml b/tests/components/at581x/common.yaml index 425be47c42..dfb3a9a05e 100644 --- a/tests/components/at581x/common.yaml +++ b/tests/components/at581x/common.yaml @@ -12,6 +12,11 @@ esphome: trigger_keep: 10s stage_gain: 3 power_consumption: 70uA + - at581x.settings: + id: waveradar + frequency: !lambda "return 5800;" + poweron_selfcheck_time: !lambda "return 2000;" + power_consumption: !lambda "return 70;" - at581x.reset: id: waveradar From 859ea23bdefdb8dd209c071394d32f12f4779fae Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Mon, 6 Apr 2026 06:33:02 +0200 Subject: [PATCH 7/7] [mitsubishi_cn105] Add climate component for Mitsubishi A/C units with CN105 connector (Part 4) (#15462) --- .../components/mitsubishi_cn105/climate.py | 16 +- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 225 +++++++++++------- .../mitsubishi_cn105/mitsubishi_cn105.h | 63 ++++- .../mitsubishi_cn105_climate.cpp | 78 +++++- .../mitsubishi_cn105_climate.h | 1 + .../climate/mitsubishi_cn105_tests.cpp | 12 +- 6 files changed, 291 insertions(+), 104 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 5ea72d4cd2..7fa6825ea6 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -8,6 +8,8 @@ DEPENDENCIES = ["uart"] AUTO_LOAD = ["climate"] CODEOWNERS = ["@crnjan"] +CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval" + mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") MitsubishiCN105Climate = mitsubishi_ns.class_( @@ -20,7 +22,14 @@ MitsubishiCN105Climate = mitsubishi_ns.class_( CONFIG_SCHEMA = ( climate.climate_schema(MitsubishiCN105Climate) .extend(uart.UART_DEVICE_SCHEMA) - .extend({cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval}) + .extend( + { + cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, + cv.Optional( + CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" + ): cv.update_interval, + } + ) ) FINAL_VALIDATE_SCHEMA = cv.All( @@ -39,3 +48,8 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) + cg.add( + var.set_current_temperature_min_interval( + config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] + ) + ) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 0bce8da1ad..3b29f66bf1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -22,7 +22,33 @@ 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 std::array, 9> PROTOCOL_MODE_MAP = { + std::nullopt, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + std::nullopt, // 0x04 + std::nullopt, // 0x05 + std::nullopt, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 +}; + +static constexpr std::array, 7> PROTOCOL_FAN_MODE_MAP = { + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + std::nullopt, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 +}; + +template +static constexpr std::optional lookup(const std::array, N> &table, uint8_t value) { + return (value < N) ? table[value] : std::nullopt; +} static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); @@ -54,12 +80,14 @@ bool MitsubishiCN105::update() { 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->frame_parser_.reset(); this->set_state_(State::READ_TIMEOUT); return false; } - return this->read_incoming_bytes_(); + return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) { + return this->process_rx_packet_(type, payload, len); + }); } void MitsubishiCN105::set_state_(State new_state) { @@ -111,7 +139,7 @@ void MitsubishiCN105::did_transition_(State to) { case State::CONNECTED: this->write_timeout_start_ms_.reset(); - this->status_msg_index_ = 0; + this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::UPDATING_STATUS); break; @@ -121,10 +149,8 @@ void MitsubishiCN105::did_transition_(State to) { 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) { + if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) { + this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP; this->set_state_(State::UPDATING_STATUS); } else { this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); @@ -134,6 +160,7 @@ void MitsubishiCN105::did_transition_(State to) { case State::SCHEDULE_NEXT_STATUS_UPDATE: this->status_update_start_ms_ = get_loop_time_ms(); + this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); break; @@ -146,15 +173,26 @@ void MitsubishiCN105::did_transition_(State to) { } } +bool MitsubishiCN105::should_request_room_temperature_() const { + if (!this->is_room_temperature_enabled()) { + return false; + } + + if (!this->last_room_temperature_update_ms_.has_value()) { + return true; + } + + return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_; +} + void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { - dump_buffer_vv("TX", packet, len); + FrameParser::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_]}; + std::array payload = {this->current_status_msg_type_}; this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } @@ -163,67 +201,6 @@ void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) { 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: @@ -251,11 +228,19 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) return false; } - if (msg_type == STATUS_MSG_TYPES[this->status_msg_index_]) { + if (msg_type == this->current_status_msg_type_) { this->set_state_(State::STATUS_UPDATED); } - return previous != this->status_ && this->is_status_initialized(); + bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || + previous.fan_mode != this->status_.fan_mode || + previous.target_temperature != this->status_.target_temperature; + + if (this->is_room_temperature_enabled()) { + changed |= previous.room_temperature != this->status_.room_temperature; + } + + return changed && this->is_status_initialized(); } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { @@ -278,6 +263,9 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) return false; } + const bool i_see = payload[3] > 0x08; + this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN); + this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN); this->status_.power_on = payload[2] != 0; this->status_.target_temperature = decode_temperature(-payload[4], payload[10], 31); @@ -291,21 +279,11 @@ bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, siz } this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); + this->last_room_temperature_update_ms_ = get_loop_time_ms(); + 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: @@ -328,4 +306,79 @@ const LogString *MitsubishiCN105::state_to_string(State state) { return LOG_STR("Unknown"); } +template +bool MitsubishiCN105::FrameParser::read_and_parse(uart::UARTDevice &device, Callback &&callback) { + uint8_t watchdog = 64; + while (device.available() > 0 && watchdog-- > 0) { + uint8_t &value = this->read_buffer_[this->read_pos_]; + if (!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_and_dump_buffer_("RX ignoring preamble"); + } + continue; + + case 2: + continue; + + case 3: + if (value != HEADER_BYTE_1) { + this->reset_and_dump_buffer_("RX invalid: header 1 mismatch"); + } + continue; + + case 4: + if (value != HEADER_BYTE_2) { + this->reset_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_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_and_dump_buffer_("RX invalid: checksum mismatch"); + continue; + } + + dump_buffer_vv("RX", this->read_buffer_, this->read_pos_); + const bool processed = + callback(this->read_buffer_[1], this->read_buffer_ + HEADER_LEN, len_without_checksum - HEADER_LEN); + this->read_pos_ = 0; + return processed; + } + + return false; +} + +void MitsubishiCN105::FrameParser::reset_and_dump_buffer_(const char *prefix) { + dump_buffer_vv(prefix, this->read_buffer_, this->read_pos_); + this->read_pos_ = 0; +} + +void MitsubishiCN105::FrameParser::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 +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index d43904b313..6a29763696 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -9,11 +9,30 @@ uint32_t get_loop_time_ms(); class MitsubishiCN105 { public: - struct Status { - bool operator==(const Status &) const = default; + enum class Mode : uint8_t { + HEAT, + DRY, + COOL, + FAN_ONLY, + AUTO, + UNKNOWN, + }; + enum class FanMode : uint8_t { + AUTO, + QUIET, + SPEED_1, + SPEED_2, + SPEED_3, + SPEED_4, + UNKNOWN, + }; + + struct Status { bool power_on{false}; float target_temperature{NAN}; + Mode mode{Mode::UNKNOWN}; + FanMode fan_mode{FanMode::UNKNOWN}; float room_temperature{NAN}; }; @@ -25,8 +44,17 @@ class MitsubishiCN105 { 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; } + uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; } + bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; } + void set_room_temperature_min_interval(uint32_t interval_ms) { + this->room_temperature_min_interval_ms_ = interval_ms; + } + const Status &status() const { return this->status_; } - bool is_status_initialized() const { return !std::isnan(status_.room_temperature); } + bool is_status_initialized() const { + return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature) + : !std::isnan(this->status_.target_temperature); + } protected: enum class State : uint8_t { @@ -40,35 +68,46 @@ class MitsubishiCN105 { READ_TIMEOUT }; + class FrameParser { + public: + template bool read_and_parse(uart::UARTDevice &device, Callback &&callback); + void reset() { read_pos_ = 0; } + static void dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len); + + protected: + void reset_and_dump_buffer_(const char *prefix); + + private: + static constexpr size_t READ_BUFFER_SIZE = 32; + uint8_t read_buffer_[READ_BUFFER_SIZE]; + uint8_t read_pos_{0}; + }; + 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); + bool should_request_room_temperature_() const; 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}; + uint32_t room_temperature_min_interval_ms_{60000}; std::optional write_timeout_start_ms_; std::optional status_update_start_ms_; + std::optional last_room_temperature_update_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}; + uint8_t current_status_msg_type_{0}; + FrameParser frame_parser_; }; } // 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 55fc23c449..21ce272993 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -6,8 +6,42 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.climate"; +static constexpr std::array MODE_MAP{ + std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO}, + std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT}, + std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY}, + std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL}, + std::pair{MitsubishiCN105::Mode::FAN_ONLY, climate::CLIMATE_MODE_FAN_ONLY}, +}; + +static constexpr std::array FAN_MODE_MAP{ + std::pair{MitsubishiCN105::FanMode::AUTO, climate::CLIMATE_FAN_AUTO}, + std::pair{MitsubishiCN105::FanMode::QUIET, climate::CLIMATE_FAN_QUIET}, + std::pair{MitsubishiCN105::FanMode::SPEED_1, climate::CLIMATE_FAN_LOW}, + std::pair{MitsubishiCN105::FanMode::SPEED_2, climate::CLIMATE_FAN_MEDIUM}, + std::pair{MitsubishiCN105::FanMode::SPEED_3, climate::CLIMATE_FAN_MIDDLE}, + std::pair{MitsubishiCN105::FanMode::SPEED_4, climate::CLIMATE_FAN_HIGH}, +}; + +template +static bool map_lookup(const std::array, N> &map, A key, B &out) { + for (const auto &[from, to] : map) { + if (from == key) { + out = to; + return true; + } + } + return false; +} + void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); + if (this->hp_.is_room_temperature_enabled()) { + ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms", + this->hp_.get_room_temperature_min_interval()); + } else { + ESP_LOGCONFIG(TAG, " Current temperature: disabled"); + } ESP_LOGCONFIG(TAG, " Update interval: %" PRIu32 " ms\n" " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", @@ -26,12 +60,32 @@ void MitsubishiCN105Climate::loop() { climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; - traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_AUTO, + }); + + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_MIDDLE, + climate::CLIMATE_FAN_HIGH, + }); 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); + + if (this->hp_.is_room_temperature_enabled()) { + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_visual_current_temperature_step(0.5f); + } return traits; } @@ -42,7 +96,25 @@ void MitsubishiCN105Climate::apply_values_() { const auto &status = this->hp_.status(); this->target_temperature = status.target_temperature; - this->current_temperature = status.room_temperature; + + if (this->hp_.is_room_temperature_enabled()) { + this->current_temperature = status.room_temperature; + } + + if (status.power_on) { + if (!map_lookup(MODE_MAP, status.mode, this->mode)) { + ESP_LOGD(TAG, "Unable to map mode"); + } + } else { + this->mode = climate::CLIMATE_MODE_OFF; + } + + climate::ClimateFanMode fan_mode; + if (map_lookup(FAN_MODE_MAP, status.fan_mode, fan_mode)) { + this->fan_mode = fan_mode; + } else { + ESP_LOGD(TAG, "Unable to map fan mode"); + } this->publish_state(); } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index da8f8d8d0a..eee4c20966 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -19,6 +19,7 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public void control(const climate::ClimateCall &call) override; void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); } + void set_current_temperature_min_interval(uint32_t ms) { hp_.set_room_temperature_min_interval(ms); } protected: void apply_values_(); diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 5b4f84623e..f26b0d82b6 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -60,6 +60,8 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { // Settings should still have initial values EXPECT_FALSE(ctx.sut.status().power_on); EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan()); + EXPECT_EQ(ctx.sut.status().mode, TestableMitsubishiCN105::Mode::UNKNOWN); + EXPECT_EQ(ctx.sut.status().fan_mode, TestableMitsubishiCN105::FanMode::UNKNOWN); ctx.sut.set_current_time(300); ASSERT_FALSE(ctx.sut.update()); @@ -68,6 +70,8 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { // 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); + EXPECT_EQ(ctx.sut.status().mode, TestableMitsubishiCN105::Mode::AUTO); + EXPECT_EQ(ctx.sut.status().fan_mode, TestableMitsubishiCN105::FanMode::AUTO); // Now fetch room temperature (0x03) EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); @@ -260,24 +264,28 @@ 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}); + {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55}); ctx.sut.update(); EXPECT_TRUE(ctx.sut.status().power_on); EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); + EXPECT_EQ(ctx.sut.status().mode, TestableMitsubishiCN105::Mode::COOL); + EXPECT_EQ(ctx.sut.status().fan_mode, TestableMitsubishiCN105::FanMode::QUIET); } 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}); + {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD}); ctx.sut.update(); EXPECT_FALSE(ctx.sut.status().power_on); EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); + EXPECT_EQ(ctx.sut.status().mode, TestableMitsubishiCN105::Mode::FAN_ONLY); + EXPECT_EQ(ctx.sut.status().fan_mode, TestableMitsubishiCN105::FanMode::SPEED_4); } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) {