From c99e60be7dca29361cb64e45f689fb3646994231 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:51:12 +0200 Subject: [PATCH 01/10] Add extended OTA protocol (split from PR #15780) --- esphome/__main__.py | 11 +- esphome/components/esphome/ota/__init__.py | 13 +- .../components/esphome/ota/ota_esphome.cpp | 65 +++++- esphome/components/esphome/ota/ota_esphome.h | 3 + esphome/components/ota/ota_backend.h | 8 + esphome/espota2.py | 190 +++++++++++------- .../ota/test-partition_access.esp32-idf.yaml | 5 + tests/unit_tests/test_espota2.py | 27 ++- tests/unit_tests/test_main.py | 23 ++- 9 files changed, 242 insertions(+), 103 deletions(-) create mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml diff --git a/esphome/__main__.py b/esphome/__main__.py index 781bcd62885..e7ce36ae2d2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1125,15 +1125,16 @@ def upload_program( remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) - if getattr(args, "file", None) is not None: - binary = Path(args.file) - else: - binary = CORE.firmware_bin # Resolve MQTT magic strings to actual IP addresses network_devices = _resolve_network_devices(devices, config, args) - return espota2.run_ota(network_devices, remote_port, password, binary) + binary = CORE.firmware_bin + ota_type = espota2.OTA_TYPE_UPDATE_APP + if getattr(args, "file", None) is not None: + binary = Path(args.file) + + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bfa5ffb55ce..ee3b7f0c20d 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,11 +16,13 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType +CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" + _LOGGER = logging.getLogger(__name__) @@ -75,6 +77,10 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) + if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32: + raise cv.Invalid( + f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" + ) else: new_ota_conf.append(ota_conf) @@ -125,6 +131,7 @@ CONFIG_SCHEMA = cv.All( ln882x=8820, rtl87xx=8892, ): cv.port, + cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" @@ -159,6 +166,10 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) + + if config.get(CONF_ALLOW_PARTITION_ACCESS): + cg.add_define("USE_OTA_PARTITIONS") + # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index be771eb6899..0796a976774 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,6 +100,9 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif +#ifdef USE_OTA_PARTITIONS + ESP_LOGCONFIG(TAG, " Partition access allowed"); +#endif } void ESPHomeOTAComponent::loop() { @@ -114,8 +117,11 @@ void ESPHomeOTAComponent::loop() { this->handle_handshake_(); } -static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -201,16 +207,36 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); - this->handshake_buf_[0] = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; + + const bool supports_compression = + (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); + + // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client + // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); + // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto + // branch so the legacy branch reads as unconditional code in either build configuration. +#ifdef USE_OTA_PARTITIONS + this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; + if (this->extended_proto_) { + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; + this->handshake_buf_[1] = + SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); + } else +#endif + { + this->handshake_buf_[0] = + supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; + } [[fallthrough]]; } case OTAState::FEATURE_ACK: { - // Acknowledge header - 1 byte - if (!this->try_write_(1, LOG_STR("ack feature"))) { +#ifdef USE_OTA_PARTITIONS + const size_t ack_size = this->extended_proto_ ? 2 : 1; +#else + const size_t ack_size = 1; +#endif + if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } #ifdef USE_OTA_PASSWORD @@ -296,6 +322,9 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; +#ifdef USE_OTA_PARTITIONS + ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; +#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -311,6 +340,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); +#ifdef USE_OTA_PARTITIONS + if (this->extended_proto_) { + // Read ota type, 1 byte + if (!this->readall_(buf, 1)) { + this->log_read_error_(LOG_STR("OTA type")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + ota_type = static_cast(buf[0]); + } + ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); +#endif + // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); @@ -331,6 +372,12 @@ void ESPHomeOTAComponent::handle_data_() { #endif // This will block for a few seconds as it locks flash +#ifdef USE_OTA_PARTITIONS + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } +#endif error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -616,7 +663,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } bool ESPHomeOTAComponent::select_auth_type_() { - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + bool client_supports_sha256 = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_SHA256_AUTH) != 0; // Require SHA256 if (!client_supports_sha256) { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 53288fc0005..9bed9240aa8 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,6 +91,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD +#ifdef USE_OTA_PARTITIONS + bool extended_proto_{false}; +#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index bd9c4819010..7e7b0f6523c 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -4,6 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include + #ifdef USE_OTA_STATE_LISTENER #include #endif @@ -23,6 +25,7 @@ enum OTAResponseTypes { OTA_RESPONSE_UPDATE_END_OK = 0x45, OTA_RESPONSE_SUPPORTS_COMPRESSION = 0x46, OTA_RESPONSE_CHUNK_OK = 0x47, + OTA_RESPONSE_FEATURE_FLAGS = 0x48, OTA_RESPONSE_ERROR_MAGIC = 0x80, OTA_RESPONSE_ERROR_UPDATE_PREPARE = 0x81, @@ -38,6 +41,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B, OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, + OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -49,6 +53,10 @@ enum OTAState { OTA_ERROR, }; +enum OTAType : uint8_t { + OTA_TYPE_UPDATE_APP = 0x00, +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/espota2.py b/esphome/espota2.py index 39f51e02e93..d81ea8454ee 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -15,6 +15,8 @@ from typing import Any from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +OTA_TYPE_UPDATE_APP = 0x00 + RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 RESPONSE_REQUEST_SHA256_AUTH = 0x02 @@ -27,6 +29,7 @@ RESPONSE_RECEIVE_OK = 0x44 RESPONSE_UPDATE_END_OK = 0x45 RESPONSE_SUPPORTS_COMPRESSION = 0x46 RESPONSE_CHUNK_OK = 0x47 +RESPONSE_FEATURE_FLAGS = 0x48 RESPONSE_ERROR_MAGIC = 0x80 RESPONSE_ERROR_UPDATE_PREPARE = 0x81 @@ -42,6 +45,7 @@ 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_UNSUPPORTED_OTA_TYPE = 0x8E RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -49,9 +53,11 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] -FEATURE_SUPPORTS_COMPRESSION = 0x01 -FEATURE_SUPPORTS_SHA256_AUTH = 0x02 - +CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01 +CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 +CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 +SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 +SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 @@ -64,6 +70,62 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +# Error response code -> human-readable message (without the "Error: " prefix; check_error() +# prepends it uniformly). Looked up by check_error() to translate a single byte from the device +# into an OTAError. Add new error codes here rather than extending the if-chain in check_error(). +_ERROR_MESSAGES: dict[int, str] = { + RESPONSE_ERROR_MAGIC: "Invalid magic byte", + RESPONSE_ERROR_UPDATE_PREPARE: ( + "Couldn't prepare flash memory for update. Is the binary too big? " + "Please try restarting the ESP." + ), + RESPONSE_ERROR_AUTH_INVALID: "Authentication invalid. Is the password correct?", + RESPONSE_ERROR_WRITING_FLASH: ( + "Writing OTA data to flash memory failed. See USB logs for more information." + ), + RESPONSE_ERROR_UPDATE_END: ( + "Finishing update failed. See the MQTT/USB logs for more information." + ), + RESPONSE_ERROR_INVALID_BOOTSTRAPPING: ( + "Please press the reset button on the ESP. A manual reset is " + "required on the first OTA-Update after flashing via USB." + ), + RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: ( + "ESP has been flashed with wrong flash size. Please choose the " + "correct 'board' option (esp01_1m always works) and then flash over USB." + ), + RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: ( + "ESP does not have the requested flash size (wrong board). Please " + "choose the correct 'board' option (esp01_1m always works) and try " + "uploading again." + ), + RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: ( + "ESP does not have enough space to store OTA file. Please try " + "flashing a minimal firmware (remove everything except ota)" + ), + RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: ( + "The OTA partition on the ESP is too small. ESPHome needs to resize " + "this partition, please flash over USB." + ), + RESPONSE_ERROR_NO_UPDATE_PARTITION: ( + "The OTA partition on the ESP couldn't be found. ESPHome needs to " + "create this partition, please flash over USB." + ), + RESPONSE_ERROR_MD5_MISMATCH: ( + "Application MD5 code mismatch. Please try again " + "or flash over USB with a good quality cable." + ), + RESPONSE_ERROR_SIGNATURE_INVALID: ( + "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." + ), + RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: ( + "The requested OTA type is not supported by the device." + ), + RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", +} + class OTAError(EsphomeError): pass @@ -139,69 +201,9 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "a network issue, or the connection was interrupted." ) dat = data[0] - if dat == RESPONSE_ERROR_MAGIC: - raise OTAError("Error: Invalid magic byte") - if dat == RESPONSE_ERROR_UPDATE_PREPARE: - raise OTAError( - "Error: Couldn't prepare flash memory for update. Is the binary too big? " - "Please try restarting the ESP." - ) - if dat == RESPONSE_ERROR_AUTH_INVALID: - raise OTAError("Error: Authentication invalid. Is the password correct?") - if dat == RESPONSE_ERROR_WRITING_FLASH: - raise OTAError( - "Error: Writing OTA data to flash memory failed. See USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_UPDATE_END: - raise OTAError( - "Error: Finishing update failed. See the MQTT/USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_INVALID_BOOTSTRAPPING: - raise OTAError( - "Error: Please press the reset button on the ESP. A manual reset is " - "required on the first OTA-Update after flashing via USB." - ) - if dat == RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: - raise OTAError( - "Error: ESP has been flashed with wrong flash size. Please choose the " - "correct 'board' option (esp01_1m always works) and then flash over USB." - ) - if dat == RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: - raise OTAError( - "Error: ESP does not have the requested flash size (wrong board). Please " - "choose the correct 'board' option (esp01_1m always works) and try " - "uploading again." - ) - if dat == RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: ESP does not have enough space to store OTA file. Please try " - "flashing a minimal firmware (remove everything except ota)" - ) - if dat == RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: The OTA partition on the ESP is too small. ESPHome needs to resize " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_NO_UPDATE_PARTITION: - raise OTAError( - "Error: The OTA partition on the ESP couldn't be found. ESPHome needs to create " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_MD5_MISMATCH: - raise OTAError( - "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") + error_msg = _ERROR_MESSAGES.get(dat) + if error_msg is not None: + raise OTAError(f"Error: {error_msg}") if not isinstance(expect, (list, tuple)): expect = [expect] if dat not in expect: @@ -232,7 +234,11 @@ def send_check( def perform_ota( - sock: socket.socket, password: str | None, file_handle: io.IOBase, filename: Path + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -251,7 +257,11 @@ def perform_ota( ) # Features - send both compression and SHA256 auth support - features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + features_to_send = ( + CLIENT_FEATURE_SUPPORTS_COMPRESSION + | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ) send_check(sock, features_to_send, "features") features = receive_exactly( sock, @@ -260,7 +270,30 @@ def perform_ota( None, # Accept any response )[0] - if features == RESPONSE_SUPPORTS_COMPRESSION: + extended_proto = False + if features == RESPONSE_FEATURE_FLAGS: + extended_proto = True + features = receive_exactly( + sock, + 1, + "feature flags", + None, # Accept any response + )[0] + elif features == RESPONSE_SUPPORTS_COMPRESSION: + features = SERVER_FEATURE_SUPPORTS_COMPRESSION + else: + features = 0 + + if ota_type not in (OTA_TYPE_UPDATE_APP): + raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") + + if ( + ota_type != OTA_TYPE_UPDATE_APP + and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ): + raise OTAError("Device only supports app updates") + + if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) else: @@ -315,6 +348,9 @@ def perform_ota( # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) + if extended_proto: + send_check(sock, ota_type, "ota type") + upload_size = len(upload_contents) upload_size_encoded = [ (upload_size >> 24) & 0xFF, @@ -375,7 +411,11 @@ def perform_ota( def run_ota_impl_( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -413,7 +453,7 @@ def run_ota_impl_( _LOGGER.info("Connected to %s", sa[0]) with open(filename, "rb") as file_handle: try: - perform_ota(sock, password, file_handle, filename) + perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: _LOGGER.error(str(err)) return 1, None @@ -428,10 +468,14 @@ def run_ota_impl_( def run_ota( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: try: - return run_ota_impl_(remote_host, remote_port, password, filename) + return run_ota_impl_(remote_host, remote_port, password, filename, ota_type) except OTAError as err: _LOGGER.error(err) return 1, None diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml new file mode 100644 index 00000000000..0cbf8549520 --- /dev/null +++ b/tests/components/ota/test-partition_access.esp32-idf.yaml @@ -0,0 +1,5 @@ +ota: + - platform: esphome + allow_partition_access: true + +<<: !include common.yaml diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 20ba4b1f760..7ac88ee1295 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -185,6 +185,14 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: "Error: The OTA partition on the ESP couldn't be found", ), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + ( + espota2.RESPONSE_ERROR_SIGNATURE_INVALID, + "Error: Firmware signature verification failed", + ), + ( + espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, + "Error: The requested OTA type is not supported by the device", + ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -270,12 +278,13 @@ def test_perform_ota_successful_md5_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -640,12 +649,13 @@ def test_perform_ota_successful_sha256_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -699,8 +709,9 @@ def test_perform_ota_sha256_fallback_to_md5( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fb8f206a1d2..186d8a9573c 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -83,6 +83,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.espota2 import OTA_TYPE_UPDATE_APP from esphome.util import BootselResult from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1593,7 +1594,7 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware + ["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1624,7 +1625,7 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin") + ["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP ) @@ -1682,7 +1683,7 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1730,7 +1731,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware + ["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -3207,7 +3208,11 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware + ["192.168.1.100", "192.168.2.50"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) @@ -3250,7 +3255,11 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) @@ -3415,7 +3424,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) From da4c4332084bf9b27a8f4028fba9199be21b8193 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:06:55 +0200 Subject: [PATCH 02/10] Fix --- esphome/espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index d81ea8454ee..ec9cc0f90a1 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -284,7 +284,7 @@ def perform_ota( else: features = 0 - if ota_type not in (OTA_TYPE_UPDATE_APP): + if ota_type != OTA_TYPE_UPDATE_APP: raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") if ( From 5535b05bf57c720429e714559be953934bf4c78c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:13:40 +0200 Subject: [PATCH 03/10] Fix tests --- esphome/espota2.py | 6 --- tests/unit_tests/test_espota2.py | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index ec9cc0f90a1..e1cc52e7ce2 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -287,12 +287,6 @@ def perform_ota( if ota_type != OTA_TYPE_UPDATE_APP: raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") - if ( - ota_type != OTA_TYPE_UPDATE_APP - and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS - ): - raise OTAError("Device only supports app updates") - if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 7ac88ee1295..37b9ef173fc 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -776,3 +776,80 @@ def test_perform_ota_version_differences( # For v2.0, verify more recv calls due to chunk acknowledgments assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_extended_protocol_app( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA partition table update.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Device feature flags + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + espota2.OTA_TYPE_UPDATE_APP, + ) + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support + extended protocol) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ] + ) + ) + + # Verify ota type was sent + assert mock_socket.sendall.call_args_list[2] == call( + bytes([espota2.OTA_TYPE_UPDATE_APP]) + ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_extended_protocol_unsupported_type( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA fails when OTA type is unsupported by the client.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Unsupported OTA type"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + 255, + ) From 68334cdd440109a1e0ee2224e327530b9acac8e8 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:02:07 +0200 Subject: [PATCH 04/10] Remove conditional compilation --- esphome/components/esphome/ota/__init__.py | 13 +---------- .../components/esphome/ota/ota_esphome.cpp | 22 +++---------------- esphome/components/esphome/ota/ota_esphome.h | 2 -- tests/unit_tests/test_espota2.py | 6 ++--- 4 files changed, 7 insertions(+), 36 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index ee3b7f0c20d..bfa5ffb55ce 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,13 +16,11 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType -CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" - _LOGGER = logging.getLogger(__name__) @@ -77,10 +75,6 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32: - raise cv.Invalid( - f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" - ) else: new_ota_conf.append(ota_conf) @@ -131,7 +125,6 @@ CONFIG_SCHEMA = cv.All( ln882x=8820, rtl87xx=8892, ): cv.port, - cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" @@ -166,10 +159,6 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) - - if config.get(CONF_ALLOW_PARTITION_ACCESS): - cg.add_define("USE_OTA_PARTITIONS") - # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 0796a976774..f7395cbbfd8 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,9 +100,6 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif -#ifdef USE_OTA_PARTITIONS - ESP_LOGCONFIG(TAG, " Partition access allowed"); -#endif } void ESPHomeOTAComponent::loop() { @@ -211,19 +208,16 @@ void ESPHomeOTAComponent::handle_handshake_() { const bool supports_compression = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); - // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client + // Compose the feature-ack response. When the client // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto // branch so the legacy branch reads as unconditional code in either build configuration. -#ifdef USE_OTA_PARTITIONS this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); - } else -#endif - { + } else { this->handshake_buf_[0] = supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; } @@ -231,11 +225,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } case OTAState::FEATURE_ACK: { -#ifdef USE_OTA_PARTITIONS const size_t ack_size = this->extended_proto_ ? 2 : 1; -#else - const size_t ack_size = 1; -#endif if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } @@ -322,9 +312,7 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; -#ifdef USE_OTA_PARTITIONS ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; -#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -340,7 +328,6 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); -#ifdef USE_OTA_PARTITIONS if (this->extended_proto_) { // Read ota type, 1 byte if (!this->readall_(buf, 1)) { @@ -350,7 +337,6 @@ void ESPHomeOTAComponent::handle_data_() { ota_type = static_cast(buf[0]); } ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); -#endif // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { @@ -371,13 +357,11 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // This will block for a few seconds as it locks flash -#ifdef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } -#endif + // This will block for a few seconds as it locks flash error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 9bed9240aa8..f612451ab03 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,9 +91,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD -#ifdef USE_OTA_PARTITIONS bool extended_proto_{false}; -#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 37b9ef173fc..1baf3f5db43 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -782,7 +782,7 @@ def test_perform_ota_version_differences( def test_perform_ota_extended_protocol_app( mock_socket: Mock, mock_file: io.BytesIO ) -> None: - """Test OTA partition table update.""" + """Test OTA extended protocol app update.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number @@ -807,7 +807,7 @@ def test_perform_ota_extended_protocol_app( mock_socket, "testpass", mock_file, - "partitions.bin", + "test.bin", espota2.OTA_TYPE_UPDATE_APP, ) @@ -850,6 +850,6 @@ def test_perform_ota_extended_protocol_unsupported_type( mock_socket, "testpass", mock_file, - "partitions.bin", + "test.bin", 255, ) From 5aef5df18b1e6ec41b0ee55c010f797448364b9c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:16:52 +0200 Subject: [PATCH 05/10] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f7395cbbfd8..ae83678f3b1 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -215,8 +215,7 @@ void ESPHomeOTAComponent::handle_handshake_() { this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = - SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); + this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); } else { this->handshake_buf_[0] = supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; @@ -225,7 +224,9 @@ void ESPHomeOTAComponent::handle_handshake_() { } case OTAState::FEATURE_ACK: { - const size_t ack_size = this->extended_proto_ ? 2 : 1; + static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1; + static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2; + const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } From cba3fc51be9c52bd85524a2652d375ea8421ddc1 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:18:30 +0200 Subject: [PATCH 06/10] Delete partition_access test --- tests/components/ota/test-partition_access.esp32-idf.yaml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml deleted file mode 100644 index 0cbf8549520..00000000000 --- a/tests/components/ota/test-partition_access.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -ota: - - platform: esphome - allow_partition_access: true - -<<: !include common.yaml From b8dfffdf062f47c1097c0a8afa28305218275915 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 21:20:07 -0500 Subject: [PATCH 07/10] [core] Enable ruff FLY (flynt) lint family (#16182) --- esphome/platformio_runner.py | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py index 599c9408a49..5b14a725577 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio_runner.py @@ -101,7 +101,7 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init -_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" +_IGNORE_LIB_WARNINGS = "(?:Hash|Update)" # Regex patterns matched against each line of PlatformIO output. Lines that # match are dropped by RedirectText before they reach the parent process. # Patterns are anchored at the start of the line (RedirectText uses diff --git a/pyproject.toml b/pyproject.toml index dc6785001d3..d16bf2b6255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ exclude = ['generated'] select = [ "E", # pycodestyle "F", # pyflakes/autoflake + "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb "I", # isort "PERF", # performance From 0980630f6820300cfc5a1f97df3d8f333987e9ad Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 May 2026 12:23:14 +1000 Subject: [PATCH 08/10] [lvgl] Clamp values for meter line indicators (#16180) --- esphome/components/lvgl/lvgl_esphome.cpp | 6 ++++-- esphome/components/lvgl/lvgl_esphome.h | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0308e6b783f..eb85faa16cd 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -454,10 +454,12 @@ void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) { #ifdef USE_LVGL_METER -int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value) { +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value) { auto *scale = lv_obj_get_parent(obj); auto min_value = lv_scale_get_range_min_value(scale); - return ((value - min_value) * lv_scale_get_angle_range(scale) / (lv_scale_get_range_max_value(scale) - min_value) + + auto max_value = lv_scale_get_range_max_value(scale); + value = clamp(value, min_value, max_value); + return ((value - min_value) * lv_scale_get_angle_range(scale) / (max_value - min_value) + lv_scale_get_rotation((scale))) % 360; } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 83cf9cc0995..be1f150affe 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -112,7 +112,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images #endif // USE_LVGL_ANIMIMG #ifdef USE_LVGL_METER -int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value); +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); #endif #ifdef USE_LVGL_GRADIENT From 5cc447e0da5eb24c53afe594acffcb24e8bc0bb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 21:27:31 -0500 Subject: [PATCH 09/10] [core] Move per-platform hal_platform.h into components/platform/hal.h (#16183) --- .../hal_esp32.h => components/esp32/hal.h} | 2 ++ esphome/components/esp8266/hal.cpp | 2 +- .../esp8266/hal.h} | 2 ++ esphome/components/host/hal.cpp | 2 +- .../hal/hal_host.h => components/host/hal.h} | 2 ++ esphome/components/libretiny/hal.cpp | 2 +- .../libretiny/hal.h} | 2 ++ esphome/components/rp2040/hal.cpp | 2 +- .../hal_rp2040.h => components/rp2040/hal.h} | 2 ++ esphome/components/zephyr/hal.cpp | 2 +- .../hal_zephyr.h => components/zephyr/hal.h} | 2 ++ esphome/core/hal.h | 24 +++++++++---------- 12 files changed, 29 insertions(+), 17 deletions(-) rename esphome/{core/hal/hal_esp32.h => components/esp32/hal.h} (98%) rename esphome/{core/hal/hal_esp8266.h => components/esp8266/hal.h} (98%) rename esphome/{core/hal/hal_host.h => components/host/hal.h} (96%) rename esphome/{core/hal/hal_libretiny.h => components/libretiny/hal.h} (99%) rename esphome/{core/hal/hal_rp2040.h => components/rp2040/hal.h} (98%) rename esphome/{core/hal/hal_zephyr.h => components/zephyr/hal.h} (97%) diff --git a/esphome/core/hal/hal_esp32.h b/esphome/components/esp32/hal.h similarity index 98% rename from esphome/core/hal/hal_esp32.h rename to esphome/components/esp32/hal.h index d5d7752bf6a..2180f07f6c3 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/components/esp32/hal.h @@ -15,6 +15,8 @@ #define PROGMEM #endif +namespace esphome::esp32 {} + namespace esphome { // Forward decl from helpers.h (esphome/core/helpers.h) — kept here so this diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index 56910e5b399..e8f472dc8a6 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -18,7 +18,7 @@ namespace esphome::esp8266 {} // namespace esphome::esp8266 namespace esphome { // yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(), -// progmem_read_*() are inlined in core/hal/hal_esp8266.h. +// progmem_read_*() are inlined in components/esp8266/hal.h. // // Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit // multiplies on the LX106). Tracks a running ms counter from 32-bit diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/components/esp8266/hal.h similarity index 98% rename from esphome/core/hal/hal_esp8266.h rename to esphome/components/esp8266/hal.h index b6e3b1ee3cb..effa9c93719 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/components/esp8266/hal.h @@ -25,6 +25,8 @@ extern "C" unsigned long millis(void); // NOLINTNEXTLINE(readability-redundant-declaration) extern "C" void system_soft_wdt_feed(void); +namespace esphome::esp8266 {} + namespace esphome { // Forward decl from helpers.h so this header stays cheap. diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index 256a12ac624..c7fef8d2e86 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -15,7 +15,7 @@ namespace esphome::host {} // namespace esphome::host namespace esphome { // yield(), arch_init(), arch_feed_wdt(), arch_get_cpu_freq_hz() inlined in -// core/hal/hal_host.h. +// components/host/hal.h. uint32_t IRAM_ATTR HOT millis() { struct timespec spec; diff --git a/esphome/core/hal/hal_host.h b/esphome/components/host/hal.h similarity index 96% rename from esphome/core/hal/hal_host.h rename to esphome/components/host/hal.h index d7f317176ea..12abf6684d3 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/components/host/hal.h @@ -8,6 +8,8 @@ #define IRAM_ATTR #define PROGMEM +namespace esphome::host {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index e6dbb7296c5..67e902024d3 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -16,7 +16,7 @@ namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), // arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() -// inlined in core/hal/hal_libretiny.h. +// inlined in components/libretiny/hal.h. void arch_init() { libretiny::setup_preferences(); diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/components/libretiny/hal.h similarity index 99% rename from esphome/core/hal/hal_libretiny.h rename to esphome/components/libretiny/hal.h index db0fc11bfbe..9c512504b72 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/components/libretiny/hal.h @@ -61,6 +61,8 @@ extern "C" void lt_wdt_feed(void); extern "C" uint32_t lt_cpu_get_cycle_count(void); extern "C" uint32_t lt_cpu_get_freq(void); +namespace esphome::libretiny {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2040/hal.cpp index 7475205d60c..e71d3fd54d6 100644 --- a/esphome/components/rp2040/hal.cpp +++ b/esphome/components/rp2040/hal.cpp @@ -17,7 +17,7 @@ namespace esphome::rp2040 {} // namespace esphome::rp2040 namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), -// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in core/hal/hal_rp2040.h. +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/components/rp2040/hal.h similarity index 98% rename from esphome/core/hal/hal_rp2040.h rename to esphome/components/rp2040/hal.h index 27a9b23c0b6..c9c61c921da 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/components/rp2040/hal.h @@ -25,6 +25,8 @@ extern "C" uint64_t time_us_64(void); extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); +namespace esphome::rp2040 {} + namespace esphome { // Forward decl from helpers.h. diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index 5c08ed25196..6c405b650ec 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -20,7 +20,7 @@ static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), // arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in -// core/hal/hal_zephyr.h. +// components/zephyr/hal.h. void arch_init() { #ifdef CONFIG_WATCHDOG diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/components/zephyr/hal.h similarity index 97% rename from esphome/core/hal/hal_zephyr.h rename to esphome/components/zephyr/hal.h index 613b3911c11..11994b68b7b 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/components/zephyr/hal.h @@ -9,6 +9,8 @@ #define IRAM_ATTR #define PROGMEM +namespace esphome::zephyr {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/core/hal.h b/esphome/core/hal.h index a53296979c6..4babda807d9 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -8,22 +8,22 @@ // Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), // inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem -// helpers) live under esphome/core/hal/ and are dispatched here based on -// the active USE_* platform define. Each header guards its body with the -// matching #ifdef USE_ and re-enters namespace esphome {} so it -// is safe to be re-included. +// helpers) live next to each platform component as components//hal.h +// and are dispatched here based on the active USE_* platform define. Each +// header guards its body with the matching #ifdef USE_ and re-enters +// namespace esphome {} so it is safe to be re-included. #if defined(USE_ESP32) -#include "esphome/core/hal/hal_esp32.h" +#include "esphome/components/esp32/hal.h" #elif defined(USE_ESP8266) -#include "esphome/core/hal/hal_esp8266.h" +#include "esphome/components/esp8266/hal.h" #elif defined(USE_LIBRETINY) -#include "esphome/core/hal/hal_libretiny.h" +#include "esphome/components/libretiny/hal.h" #elif defined(USE_RP2040) -#include "esphome/core/hal/hal_rp2040.h" +#include "esphome/components/rp2040/hal.h" #elif defined(USE_HOST) -#include "esphome/core/hal/hal_host.h" +#include "esphome/components/host/hal.h" #elif defined(USE_ZEPHYR) -#include "esphome/core/hal/hal_zephyr.h" +#include "esphome/components/zephyr/hal.h" #else #error "hal.h: not implemented for this platform" #endif @@ -33,12 +33,12 @@ namespace esphome { // Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), // arch_get_cpu_cycle_count(), arch_init(), arch_get_cpu_freq_hz() vary // per platform (some inline, some out-of-line) so they live in -// hal/hal_.h. +// components//hal.h. void __attribute__((noreturn)) arch_restart(); #ifndef USE_ESP8266 // All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. -// ESP8266's out-of-line declarations live in hal/hal_esp8266.h. +// ESP8266's out-of-line declarations live in components/esp8266/hal.h. inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } From 6182beb1f20966baff784ce195ad20c92fd913f6 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 10:15:58 +0200 Subject: [PATCH 10/10] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ae83678f3b1..66ce7a9be46 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -118,7 +118,6 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; -static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -208,10 +207,9 @@ void ESPHomeOTAComponent::handle_handshake_() { const bool supports_compression = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); - // Compose the feature-ack response. When the client - // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); - // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto - // branch so the legacy branch reads as unconditional code in either build configuration. + // Compose the feature-ack response. When the client negotiates the extended protocol we emit + // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte + // legacy response. this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;